mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
merge(codex-ma): integrate PR-E per-account home (brings PR-A)
This commit is contained in:
@@ -167,6 +167,41 @@ describe('dedupeCodexRolloutFileAliases', () => {
|
||||
|
||||
expect(dedupeCodexRolloutFileAliases([host, wsl], accessors)).toEqual([host, wsl])
|
||||
})
|
||||
|
||||
it('prefers a per-account self-contained home over other non-default homes', () => {
|
||||
const perAccount = {
|
||||
agent: 'codex',
|
||||
path: '/Users/ada/Library/Application Support/orca/codex-accounts/019f0000-aaaa-bbbb/home/sessions/2026/07/01/rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl',
|
||||
codexHome:
|
||||
'/Users/ada/Library/Application Support/orca/codex-accounts/019f0000-aaaa-bbbb/home',
|
||||
hardlinkIdentity: '3:71'
|
||||
}
|
||||
const custom = {
|
||||
agent: 'codex',
|
||||
path: '/Users/ada/custom-codex/sessions/2026/07/01/rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl',
|
||||
codexHome: '/Users/ada/custom-codex',
|
||||
hardlinkIdentity: '3:71'
|
||||
}
|
||||
expect(dedupeCodexRolloutFileAliases([custom, perAccount], accessors)).toEqual([perAccount])
|
||||
expect(dedupeCodexRolloutFileAliases([perAccount, custom], accessors)).toEqual([perAccount])
|
||||
})
|
||||
|
||||
it('keeps the real home over a per-account home when they alias', () => {
|
||||
const perAccount = {
|
||||
agent: 'codex',
|
||||
path: '/Users/ada/Library/Application Support/orca/codex-accounts/019f0000-aaaa-bbbb/home/sessions/2026/07/01/rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl',
|
||||
codexHome:
|
||||
'/Users/ada/Library/Application Support/orca/codex-accounts/019f0000-aaaa-bbbb/home',
|
||||
hardlinkIdentity: '1:42'
|
||||
}
|
||||
const real = {
|
||||
agent: 'codex',
|
||||
path: REAL_HOME_ROLLOUT,
|
||||
codexHome: null,
|
||||
hardlinkIdentity: '1:42'
|
||||
}
|
||||
expect(dedupeCodexRolloutFileAliases([perAccount, real], accessors)).toEqual([real])
|
||||
})
|
||||
})
|
||||
|
||||
describe('dedupeCodexSessionsBySessionId', () => {
|
||||
|
||||
@@ -53,16 +53,20 @@ export function codexRolloutHardlinkIdentity(file: {
|
||||
*
|
||||
* Host real home (null) is canonical: after the real-home flip the managed
|
||||
* home's auth.json is no longer refreshed, so resume must not stamp it. The
|
||||
* Orca managed runtime home still beats other homes (WSL/remote real homes,
|
||||
* custom CODEX_HOMEs) because those lanes have not flipped — their launches
|
||||
* keep managed auth, so resume keeps the managed stamp as today.
|
||||
* Orca managed runtime home and each per-account self-contained home
|
||||
* (codex-accounts/<id>/home) beat other homes (WSL/remote real homes, custom
|
||||
* CODEX_HOMEs) because those are the roots codex actually refreshes for their
|
||||
* lane — the shared runtime home for the managed mirror, and the per-account
|
||||
* home once a managed account launches directly against it.
|
||||
*/
|
||||
function codexSessionRootRank(codexHome: string | null): number {
|
||||
if (codexHome === null) {
|
||||
return 0
|
||||
}
|
||||
const segments = codexHome.split(/[\\/]/).filter(Boolean)
|
||||
return segments.at(-2) === 'codex-runtime-home' && segments.at(-1) === 'home' ? 1 : 2
|
||||
const isSharedRuntimeHome = segments.at(-2) === 'codex-runtime-home' && segments.at(-1) === 'home'
|
||||
const isPerAccountManagedHome = segments.at(-3) === 'codex-accounts' && segments.at(-1) === 'home'
|
||||
return isSharedRuntimeHome || isPerAccountManagedHome ? 1 : 2
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership'
|
||||
|
||||
describe('assertOwnedHostCodexManagedHomePath', () => {
|
||||
let rootDir: string
|
||||
let userDataDir: string
|
||||
let systemHomePath: string
|
||||
|
||||
beforeEach(() => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), 'orca-managed-home-ownership-'))
|
||||
userDataDir = join(rootDir, 'user-data')
|
||||
systemHomePath = join(rootDir, 'home', '.codex')
|
||||
mkdirSync(join(userDataDir, 'codex-accounts'), { recursive: true })
|
||||
mkdirSync(systemHomePath, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(rootDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('accepts the expected marked account home', () => {
|
||||
const accountHome = join(userDataDir, 'codex-accounts', 'account-1', 'home')
|
||||
mkdirSync(accountHome, { recursive: true })
|
||||
writeFileSync(join(accountHome, '.orca-managed-home'), 'account-1\n', 'utf-8')
|
||||
|
||||
expect(
|
||||
assertOwnedHostCodexManagedHomePath({
|
||||
candidatePath: accountHome,
|
||||
managedAccountsRoot: join(userDataDir, 'codex-accounts'),
|
||||
systemCodexHomePath: systemHomePath,
|
||||
expectedAccountId: 'account-1'
|
||||
})
|
||||
).toBe(realpathSync(accountHome))
|
||||
})
|
||||
|
||||
it('rejects an account path redirected into the sandboxed system Codex home', () => {
|
||||
const systemAccountHome = join(systemHomePath, 'account-1', 'home')
|
||||
mkdirSync(systemAccountHome, { recursive: true })
|
||||
writeFileSync(join(systemAccountHome, '.orca-managed-home'), 'account-1\n', 'utf-8')
|
||||
const sentinelPath = join(systemHomePath, 'auth.json')
|
||||
writeFileSync(sentinelPath, 'system-auth\n', 'utf-8')
|
||||
|
||||
rmSync(join(userDataDir, 'codex-accounts'), { recursive: true, force: true })
|
||||
symlinkSync(
|
||||
systemHomePath,
|
||||
join(userDataDir, 'codex-accounts'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
assertOwnedHostCodexManagedHomePath({
|
||||
candidatePath: join(userDataDir, 'codex-accounts', 'account-1', 'home'),
|
||||
managedAccountsRoot: join(userDataDir, 'codex-accounts'),
|
||||
systemCodexHomePath: systemHomePath,
|
||||
expectedAccountId: 'account-1'
|
||||
})
|
||||
).toThrow('resolves inside the system Codex home')
|
||||
expect(readFileSync(sentinelPath, 'utf-8')).toBe('system-auth\n')
|
||||
})
|
||||
|
||||
it('rejects a persisted home or marker belonging to another account', () => {
|
||||
const accountHome = join(userDataDir, 'codex-accounts', 'account-2', 'home')
|
||||
mkdirSync(accountHome, { recursive: true })
|
||||
writeFileSync(join(accountHome, '.orca-managed-home'), 'account-2\n', 'utf-8')
|
||||
|
||||
expect(() =>
|
||||
assertOwnedHostCodexManagedHomePath({
|
||||
candidatePath: accountHome,
|
||||
managedAccountsRoot: join(userDataDir, 'codex-accounts'),
|
||||
systemCodexHomePath: systemHomePath,
|
||||
expectedAccountId: 'account-1'
|
||||
})
|
||||
).toThrow('does not match its persisted account ID')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
|
||||
type HostCodexManagedHomeOwnershipOptions = {
|
||||
candidatePath: string
|
||||
managedAccountsRoot: string
|
||||
systemCodexHomePath: string
|
||||
expectedAccountId?: string
|
||||
}
|
||||
|
||||
function pathsEqual(left: string, right: string): boolean {
|
||||
const resolvedLeft = resolve(left)
|
||||
const resolvedRight = resolve(right)
|
||||
return process.platform === 'win32'
|
||||
? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
|
||||
: resolvedLeft === resolvedRight
|
||||
}
|
||||
|
||||
function pathIsInsideOrEqual(rootPath: string, candidatePath: string): boolean {
|
||||
const relativePath = relative(rootPath, candidatePath)
|
||||
return (
|
||||
relativePath === '' ||
|
||||
(!isAbsolute(relativePath) && relativePath !== '..' && !relativePath.startsWith(`..${sep}`))
|
||||
)
|
||||
}
|
||||
|
||||
function canonicalizeIfPresent(candidatePath: string): string {
|
||||
const resolvedPath = resolve(candidatePath)
|
||||
return existsSync(resolvedPath) ? realpathSync(resolvedPath) : resolvedPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Proves a host managed home is an Orca-owned account directory and cannot
|
||||
* resolve into the user's real CODEX_HOME before callers write launch state.
|
||||
*/
|
||||
export function assertOwnedHostCodexManagedHomePath({
|
||||
candidatePath,
|
||||
managedAccountsRoot,
|
||||
systemCodexHomePath,
|
||||
expectedAccountId
|
||||
}: HostCodexManagedHomeOwnershipOptions): string {
|
||||
const resolvedCandidate = resolve(candidatePath)
|
||||
const resolvedRoot = resolve(managedAccountsRoot)
|
||||
if (!existsSync(resolvedCandidate)) {
|
||||
throw new Error('Managed Codex home directory does not exist on disk.')
|
||||
}
|
||||
// Why: macOS can spell one path as /var and /private/var, while a replaced
|
||||
// path component must still be caught as a canonical containment escape.
|
||||
const canonicalCandidate = realpathSync(resolvedCandidate)
|
||||
const canonicalRoot = realpathSync(resolvedRoot)
|
||||
const canonicalSystemHome = canonicalizeIfPresent(systemCodexHomePath)
|
||||
if (expectedAccountId !== undefined) {
|
||||
const candidateUsesManagedRootSpelling =
|
||||
pathIsInsideOrEqual(resolvedRoot, resolvedCandidate) ||
|
||||
pathIsInsideOrEqual(canonicalRoot, resolvedCandidate)
|
||||
const canonicalExpectedHome = canonicalizeIfPresent(
|
||||
join(canonicalRoot, expectedAccountId, 'home')
|
||||
)
|
||||
if (
|
||||
!candidateUsesManagedRootSpelling ||
|
||||
!pathsEqual(canonicalCandidate, canonicalExpectedHome)
|
||||
) {
|
||||
throw new Error('Managed Codex home does not match its persisted account ID.')
|
||||
}
|
||||
}
|
||||
// Why: a replaced codex-accounts directory could otherwise redirect config,
|
||||
// hook, or resource writes into the user's real ~/.codex tree.
|
||||
if (pathIsInsideOrEqual(canonicalSystemHome, canonicalCandidate)) {
|
||||
throw new Error('Managed Codex home resolves inside the system Codex home.')
|
||||
}
|
||||
if (
|
||||
!pathIsInsideOrEqual(canonicalRoot, canonicalCandidate) ||
|
||||
canonicalRoot === canonicalCandidate
|
||||
) {
|
||||
throw new Error(
|
||||
`Managed Codex home is outside current storage root (expected under ${canonicalRoot}).`
|
||||
)
|
||||
}
|
||||
|
||||
const markerPath = join(canonicalCandidate, '.orca-managed-home')
|
||||
let markerIsRegularFile: boolean
|
||||
try {
|
||||
markerIsRegularFile = lstatSync(markerPath).isFile()
|
||||
} catch (error) {
|
||||
throw new Error('Managed Codex home is missing Orca ownership marker.', { cause: error })
|
||||
}
|
||||
if (!markerIsRegularFile) {
|
||||
throw new Error('Managed Codex home ownership marker is not a regular file.')
|
||||
}
|
||||
const markerContents = readFileSync(markerPath, 'utf-8')
|
||||
if (expectedAccountId !== undefined && markerContents.trim() !== expectedAccountId) {
|
||||
throw new Error('Managed Codex home ownership marker does not match its account ID.')
|
||||
}
|
||||
|
||||
return canonicalCandidate
|
||||
}
|
||||
@@ -1069,7 +1069,9 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the managed home for a host MANAGED account even when the flag is ON', async () => {
|
||||
it('routes a host MANAGED account to its own self-contained home when the flag is ON', async () => {
|
||||
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
|
||||
const runtimeAuthPath = getRuntimeCodexAuthPath()
|
||||
const managedHomePath = createManagedAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
@@ -1098,8 +1100,213 @@ describe('CodexRuntimeHomeService', () => {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
// Flag ON + host managed account = the account's own home is CODEX_HOME.
|
||||
expect(service.isHostSystemDefaultRealHome()).toBe(false)
|
||||
expect(service.prepareForCodexLaunch()).toBe(managedHomePath)
|
||||
// The per-account home keeps its own auth in place; the shared mirror's
|
||||
// auth.json is never hot-swapped, so two accounts cannot race one file.
|
||||
expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(
|
||||
'{"account":"managed"}\n'
|
||||
)
|
||||
expect(existsSync(runtimeAuthPath)).toBe(false)
|
||||
// Session discovery includes the per-account home so its rollouts surface.
|
||||
expect(service.getHostCodexHomePathsForSessionDiscovery()).toContain(managedHomePath)
|
||||
})
|
||||
|
||||
it('gives two managed accounts distinct homes without racing one auth.json (flag ON)', async () => {
|
||||
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
|
||||
const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one')
|
||||
const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two')
|
||||
const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth)
|
||||
const home2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth)
|
||||
const settings = createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: true,
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'one@example.com',
|
||||
managedHomePath: home1,
|
||||
providerAccountId: 'acct-1',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-1',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'account-2',
|
||||
email: 'two@example.com',
|
||||
managedHomePath: home2,
|
||||
providerAccountId: 'acct-2',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-2',
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
lastAuthenticatedAt: 2
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountId: 'account-1',
|
||||
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
|
||||
})
|
||||
const store = createStore(settings)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
// A pane for account-1 launches, then the user switches and a second pane
|
||||
// for account-2 launches concurrently — each gets its OWN CODEX_HOME.
|
||||
expect(service.prepareForCodexLaunch()).toBe(home1)
|
||||
settings.activeCodexManagedAccountId = 'account-2'
|
||||
settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} }
|
||||
expect(service.prepareForCodexLaunch()).toBe(home2)
|
||||
|
||||
// Nothing is hot-swapped, so the still-running account-1 pane keeps seeing
|
||||
// account-1's credentials — the single-auth.json race (GAP-5) is gone.
|
||||
expect(readFileSync(join(home1, 'auth.json'), 'utf-8')).toBe(account1Auth)
|
||||
expect(readFileSync(join(home2, 'auth.json'), 'utf-8')).toBe(account2Auth)
|
||||
expect(existsSync(getRuntimeCodexAuthPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('materializes resources and config into the per-account home on launch (flag ON)', async () => {
|
||||
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
|
||||
mkdirSync(join(getSystemCodexHomePath(), 'skills', 'review'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(getSystemCodexHomePath(), 'skills', 'review', 'SKILL.md'),
|
||||
'skill\n',
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
join(getSystemCodexHomePath(), 'config.toml'),
|
||||
'approval_policy = "never"\n',
|
||||
'utf-8'
|
||||
)
|
||||
const home1 = createManagedAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
createCodexAuthJson('one@example.com', 'acct-1', 'one')
|
||||
)
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: true,
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'one@example.com',
|
||||
managedHomePath: home1,
|
||||
providerAccountId: 'acct-1',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-1',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountId: 'account-1',
|
||||
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
|
||||
})
|
||||
)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.prepareForCodexLaunch()).toBe(home1)
|
||||
// Resources link/copy into THIS home; config mirrors into it.
|
||||
expectResourceLinkedOrCopied(join(home1, 'skills'), join(getSystemCodexHomePath(), 'skills'))
|
||||
expect(readFileSync(join(home1, 'skills', 'review', 'SKILL.md'), 'utf-8')).toBe('skill\n')
|
||||
expect(readFileSync(join(home1, 'config.toml'), 'utf-8')).toContain('approval_policy = "never"')
|
||||
// ~/.codex is never mutated: no auth churn, no per-account dir written back.
|
||||
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe('{"account":"system"}\n')
|
||||
})
|
||||
|
||||
it('points the rate-limit fetch at the per-account home when the flag is ON', async () => {
|
||||
const home1 = createManagedAuth(testState.userDataDir, 'account-1', '{"account":"managed"}\n')
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: true,
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'one@example.com',
|
||||
managedHomePath: home1,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountId: 'account-1',
|
||||
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
|
||||
})
|
||||
)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.prepareForRateLimitFetch()).toBe(home1)
|
||||
})
|
||||
|
||||
it('drops a managed selection whose auth.json vanished and resolves the real home (flag ON)', async () => {
|
||||
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
|
||||
// A managed home that has lost its auth.json (only the marker remains).
|
||||
const brokenHome = join(testState.userDataDir, 'codex-accounts', 'account-1', 'home')
|
||||
mkdirSync(brokenHome, { recursive: true })
|
||||
writeFileSync(join(brokenHome, '.orca-managed-home'), 'account-1\n', 'utf-8')
|
||||
const settings = createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: true,
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'one@example.com',
|
||||
managedHomePath: brokenHome,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountId: 'account-1',
|
||||
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
|
||||
})
|
||||
const store = createStore(settings)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
// The broken account is dropped and the launch resolves to the real home.
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
expect(store.getSettings().activeCodexManagedAccountId).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the shared runtime home + auth hot-swap for managed accounts when the flag is OFF', async () => {
|
||||
const runtimeAuthPath = getRuntimeCodexAuthPath()
|
||||
const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one')
|
||||
const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth)
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: false,
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'one@example.com',
|
||||
managedHomePath: home1,
|
||||
providerAccountId: 'acct-1',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-1',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountId: 'account-1',
|
||||
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
|
||||
})
|
||||
)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
// Flag OFF is byte-identical to today: shared mirror home + hot-swapped auth.
|
||||
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1Auth)
|
||||
})
|
||||
|
||||
it('uses the same host CODEX_HOME after switching managed Codex accounts', async () => {
|
||||
|
||||
@@ -65,6 +65,7 @@ import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-fl
|
||||
import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path'
|
||||
import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker'
|
||||
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
|
||||
import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership'
|
||||
|
||||
type CodexAuthIdentity = {
|
||||
email: string | null
|
||||
@@ -169,6 +170,15 @@ export class CodexRuntimeHomeService {
|
||||
this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath)
|
||||
return runtimeHomePath
|
||||
}
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
if (selfContainedAccount) {
|
||||
const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch(selfContainedAccount)
|
||||
if (perAccountHome) {
|
||||
return perAccountHome
|
||||
}
|
||||
// Why: the account's home lost its auth.json, so the selection was just
|
||||
// dropped. Fall through and resolve this launch as the system default.
|
||||
}
|
||||
if (this.isHostSystemDefaultRealHome(launchEnv)) {
|
||||
// Why (flag ON, system default): run Codex on the user's own ~/.codex.
|
||||
// Returning null tells the PTY/env layer to inject no managed CODEX_HOME;
|
||||
@@ -189,6 +199,105 @@ export class CodexRuntimeHomeService {
|
||||
return this.getRuntimeHomePath()
|
||||
}
|
||||
|
||||
// Why: with the real-home flag ON, a managed HOST account runs against its own
|
||||
// self-contained CODEX_HOME (codex-accounts/<id>/home) instead of the shared
|
||||
// runtime mirror. Its auth.json lives there and codex refreshes it in place,
|
||||
// so two accounts never race one auth.json (GAP-5) and the mirror can be
|
||||
// deleted once no lane still injects it (GAP-1). WSL accounts keep their
|
||||
// per-distro lane; the flag-OFF opt-out keeps the shared-home hot-swap.
|
||||
private getSelfContainedManagedHostAccount(): CodexManagedAccount | null {
|
||||
const settings = this.store.getSettings()
|
||||
if (!isCodexSystemDefaultRealHomeEnabled(settings)) {
|
||||
return null
|
||||
}
|
||||
const account = this.getActiveAccount(
|
||||
settings.codexManagedAccounts,
|
||||
normalizeCodexRuntimeSelection(settings).host
|
||||
)
|
||||
if (!account || this.getWslManagedHomePath(account)) {
|
||||
return null
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
private getSelfContainedManagedHostAccountHomes(): string[] {
|
||||
const settings = this.store.getSettings()
|
||||
if (!isCodexSystemDefaultRealHomeEnabled(settings)) {
|
||||
return []
|
||||
}
|
||||
return settings.codexManagedAccounts
|
||||
.filter((account) => !this.getWslManagedHomePath(account))
|
||||
.map((account) => account.managedHomePath)
|
||||
}
|
||||
|
||||
private prepareSelfContainedManagedHomeForLaunch(account: CodexManagedAccount): string | null {
|
||||
const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account)
|
||||
if (!perAccountHome || !existsSync(join(perAccountHome, 'auth.json'))) {
|
||||
// Why: drop the selection so this and future launches resolve to the
|
||||
// system default rather than a home codex cannot authenticate against.
|
||||
this.clearSelfContainedManagedSelection(account)
|
||||
return null
|
||||
}
|
||||
// Why: link the user's real ~/.codex resources and mirror config into THIS
|
||||
// home (never symlinking into or mutating ~/.codex), so the per-account home
|
||||
// is a complete CODEX_HOME. Hooks/trust are installed by the launch caller.
|
||||
this.lastSyncedAccountId = account.id
|
||||
syncSystemCodexResourcesIntoManagedHome(perAccountHome)
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath: perAccountHome,
|
||||
systemHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
return perAccountHome
|
||||
}
|
||||
|
||||
// Why: the per-account home is both the launch CODEX_HOME and the credential
|
||||
// store, so codex reads/refreshes auth.json in place — there is no shared-home
|
||||
// hot-swap or token read-back to reconcile. Only validate the credential
|
||||
// survives; a vanished auth.json drops the selection to the system default.
|
||||
private syncSelfContainedManagedSelection(account: CodexManagedAccount): void {
|
||||
const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account)
|
||||
if (perAccountHome && existsSync(join(perAccountHome, 'auth.json'))) {
|
||||
this.lastSyncedAccountId = account.id
|
||||
return
|
||||
}
|
||||
this.clearSelfContainedManagedSelection(account)
|
||||
}
|
||||
|
||||
private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null {
|
||||
try {
|
||||
assertOwnedHostCodexManagedHomePath({
|
||||
candidatePath: account.managedHomePath,
|
||||
managedAccountsRoot: this.getManagedAccountsRoot(),
|
||||
systemCodexHomePath: getSystemCodexHomePath(),
|
||||
expectedAccountId: account.id
|
||||
})
|
||||
// Preserve the persisted path spelling (notably /var vs /private/var on
|
||||
// macOS) so injected CODEX_HOME stays stable across the rollout.
|
||||
return account.managedHomePath
|
||||
} catch (error) {
|
||||
console.warn('[codex-runtime-home] Refusing untrusted managed account home:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private clearSelfContainedManagedSelection(account: CodexManagedAccount): void {
|
||||
console.warn(
|
||||
'[codex-runtime-home] Active managed account home is invalid or missing auth.json, clearing selection'
|
||||
)
|
||||
const settings = this.store.getSettings()
|
||||
if (normalizeCodexRuntimeSelection(settings).host !== account.id) {
|
||||
return
|
||||
}
|
||||
this.store.updateSettings({
|
||||
activeCodexManagedAccountId: null,
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
...normalizeCodexRuntimeSelection(settings),
|
||||
host: null
|
||||
}
|
||||
})
|
||||
this.lastSyncedAccountId = null
|
||||
}
|
||||
|
||||
private invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv?: NodeJS.ProcessEnv): void {
|
||||
const settings = this.store.getSettings()
|
||||
if (normalizeCodexRuntimeSelection(settings).host !== null) {
|
||||
@@ -233,11 +342,18 @@ export class CodexRuntimeHomeService {
|
||||
|
||||
getHostCodexHomePathsForSessionDiscovery(): string[] {
|
||||
const homes = [this.getRuntimeHomePath()]
|
||||
if (this.isHostSystemDefaultRealHome()) {
|
||||
// Why: nested Orca processes can retain an ambient managed CODEX_HOME;
|
||||
// explicitly include the real lane so its sessions remain discoverable.
|
||||
if (this.isHostSystemDefaultRealHome() || this.getSelfContainedManagedHostAccount()) {
|
||||
// Why: nested Orca processes can retain an ambient managed CODEX_HOME.
|
||||
// Per-account lanes no longer bridge real-home history into the shared
|
||||
// mirror, so include the real root for both directly-routed host lanes.
|
||||
homes.push(getSystemCodexHomePath())
|
||||
}
|
||||
// Why: flag ON routes each managed host account to its own self-contained
|
||||
// home, so its rollouts live there rather than in the shared mirror. Scan
|
||||
// every such home so account-scoped sessions still surface in the AI Vault.
|
||||
for (const perAccountHome of this.getSelfContainedManagedHostAccountHomes()) {
|
||||
homes.push(perAccountHome)
|
||||
}
|
||||
return homes.filter((home, index) => homes.indexOf(home) === index)
|
||||
}
|
||||
|
||||
@@ -344,6 +460,23 @@ export class CodexRuntimeHomeService {
|
||||
const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget)
|
||||
return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
|
||||
}
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
const selfContainedHome = selfContainedAccount
|
||||
? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount)
|
||||
: null
|
||||
if (
|
||||
selfContainedAccount &&
|
||||
selfContainedHome &&
|
||||
existsSync(join(selfContainedHome, 'auth.json'))
|
||||
) {
|
||||
// Why: the quota fetch reads the account's own auth.json in place; no
|
||||
// shared-home hot-swap, and no per-poll resource relink (that is launch
|
||||
// prep). Config was mirrored on add/select and refreshed at launch.
|
||||
return selfContainedHome
|
||||
}
|
||||
if (selfContainedAccount) {
|
||||
this.clearSelfContainedManagedSelection(selfContainedAccount)
|
||||
}
|
||||
if (this.isHostSystemDefaultRealHome()) {
|
||||
// Why: null lets the fetcher fall back to the main process's inherited
|
||||
// CODEX_HOME before ~/.codex. Nested Orca launches can inherit the
|
||||
@@ -363,6 +496,14 @@ export class CodexRuntimeHomeService {
|
||||
return
|
||||
}
|
||||
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
if (selfContainedAccount) {
|
||||
// Why: self-contained managed homes hold their own auth, so the shared
|
||||
// runtime home's snapshot/hot-swap/read-back machinery below must not run.
|
||||
this.syncSelfContainedManagedSelection(selfContainedAccount)
|
||||
return
|
||||
}
|
||||
|
||||
const settings = this.store.getSettings()
|
||||
const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath())
|
||||
if (this.lastSyncedAccountId === null) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command'
|
||||
import type { readHookTrustEntries as ReadHookTrustEntries } from '../codex/config-toml-trust'
|
||||
|
||||
const testState = {
|
||||
userDataDir: '',
|
||||
@@ -47,6 +48,10 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true
|
||||
const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false
|
||||
return {
|
||||
// Config-sync/hot-swap tests assert the shared-mirror path; the RC defaults
|
||||
// the real-home flag ON (per-account self-contained homes), so opt these
|
||||
// managed cases out unless a test overrides it.
|
||||
codexSystemDefaultRealHomeEnabled: overrides.codexSystemDefaultRealHomeEnabled ?? false,
|
||||
workspaceDir: testState.fakeHomeDir,
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
@@ -220,6 +225,65 @@ function createCodexAuthJson(email: string, accountId: string, refreshToken: str
|
||||
)}\n`
|
||||
}
|
||||
|
||||
async function createCanonicalHookTrustFixture(): Promise<{
|
||||
config: string
|
||||
orcaKeys: string[]
|
||||
userKey: string
|
||||
}> {
|
||||
const { MANAGED_HOOK_TIMEOUT_SECONDS } = await import('../agent-hooks/installer-utils')
|
||||
const { getCodexManagedHookInstallMaterial } = await import('../codex/hook-service')
|
||||
const { computeTrustKey, computeTrustedHash, escapeTomlString, normalizeHookTrustKeyForLookup } =
|
||||
await import('../codex/config-toml-trust')
|
||||
const { getCodexHookTrustSignature } = await import('../codex/codex-hook-identity')
|
||||
const { writeCodexTrustGrantLedgerHome } = await import('../codex/codex-trust-grant-ledger')
|
||||
const sourceHomePath = join(testState.fakeHomeDir, '.codex')
|
||||
const sourcePath = join(sourceHomePath, 'hooks.json')
|
||||
const material = getCodexManagedHookInstallMaterial()
|
||||
const expectedHashEntry = {
|
||||
sourcePath,
|
||||
eventLabel: 'stop' as const,
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: material.command,
|
||||
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
|
||||
}
|
||||
const ledgerHashEntry = {
|
||||
...expectedHashEntry,
|
||||
eventLabel: 'session_start' as const,
|
||||
groupIndex: 1
|
||||
}
|
||||
const userEntry = {
|
||||
...expectedHashEntry,
|
||||
groupIndex: 2,
|
||||
command: 'user-authored-hook'
|
||||
}
|
||||
const expectedHashKey = computeTrustKey(expectedHashEntry)
|
||||
const ledgerHashKey = computeTrustKey(ledgerHashEntry)
|
||||
const userKey = computeTrustKey(userEntry)
|
||||
const ledgerTrustedHash = 'sha256:codex-granted-orca-hook'
|
||||
writeCodexTrustGrantLedgerHome(sourceHomePath, {
|
||||
binary: null,
|
||||
entries: {
|
||||
[normalizeHookTrustKeyForLookup(ledgerHashKey)]: {
|
||||
signature: getCodexHookTrustSignature(ledgerHashEntry),
|
||||
trustedHash: ledgerTrustedHash
|
||||
}
|
||||
}
|
||||
})
|
||||
const block = (key: string, trustedHash: string): string =>
|
||||
`[hooks.state."${escapeTomlString(key)}"]\ntrusted_hash = "${trustedHash}"\nenabled = true`
|
||||
return {
|
||||
config: [
|
||||
'approval_policy = "never"',
|
||||
block(expectedHashKey, computeTrustedHash(expectedHashEntry)),
|
||||
block(ledgerHashKey, ledgerTrustedHash),
|
||||
block(userKey, computeTrustedHash(userEntry))
|
||||
].join('\n\n'),
|
||||
orcaKeys: [expectedHashKey, ledgerHashKey],
|
||||
userKey
|
||||
}
|
||||
}
|
||||
|
||||
describe('CodexAccountService config sync', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
@@ -280,6 +344,99 @@ describe('CodexAccountService config sync', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not seed source-home hook trust into a self-contained account home', async () => {
|
||||
const fixture = await createCanonicalHookTrustFixture()
|
||||
const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
|
||||
writeFileSync(canonicalConfigPath, fixture.config, 'utf-8')
|
||||
const managedHomePath = createManagedHome(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
'approval_policy = "on-request"\n'
|
||||
)
|
||||
const settings = createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: true,
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'user@example.com',
|
||||
managedHomePath,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
const store = createStore(settings)
|
||||
const rateLimits = createRateLimits()
|
||||
const runtimeHome = createRuntimeHome()
|
||||
const { readHookTrustEntries } = await import('../codex/config-toml-trust')
|
||||
const { readCodexTrustGrantLedgerHome } = await import('../codex/codex-trust-grant-ledger')
|
||||
|
||||
const { CodexAccountService } = await import('./service')
|
||||
const service = new CodexAccountService(
|
||||
store as never,
|
||||
rateLimits as never,
|
||||
runtimeHome as never
|
||||
)
|
||||
const expectSanitizedManagedConfig = (): void => {
|
||||
const entries = readHookTrustEntries(join(managedHomePath, 'config.toml'))
|
||||
for (const key of fixture.orcaKeys) {
|
||||
expect(entries.has(key)).toBe(false)
|
||||
}
|
||||
// The launch-time hook mirror remaps user trust to this home's hooks.json.
|
||||
expect(entries.has(fixture.userKey)).toBe(false)
|
||||
}
|
||||
|
||||
expectSanitizedManagedConfig()
|
||||
expect(readFileSync(canonicalConfigPath, 'utf-8')).toBe(fixture.config)
|
||||
expect(readCodexTrustGrantLedgerHome(join(testState.fakeHomeDir, '.codex'))).not.toBeNull()
|
||||
|
||||
writeFileSync(join(managedHomePath, 'config.toml'), 'approval_policy = "untrusted"\n', 'utf-8')
|
||||
await service.selectAccount('account-1')
|
||||
|
||||
expectSanitizedManagedConfig()
|
||||
})
|
||||
|
||||
it('keeps flag-off config mirroring byte-identical', async () => {
|
||||
const fixture = await createCanonicalHookTrustFixture()
|
||||
const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
|
||||
writeFileSync(canonicalConfigPath, fixture.config, 'utf-8')
|
||||
const managedHomePath = createManagedHome(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
'approval_policy = "on-request"\n'
|
||||
)
|
||||
const settings = createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: false,
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'user@example.com',
|
||||
managedHomePath,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { CodexAccountService } = await import('./service')
|
||||
new CodexAccountService(
|
||||
createStore(settings) as never,
|
||||
createRateLimits() as never,
|
||||
createRuntimeHome() as never
|
||||
)
|
||||
|
||||
expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(fixture.config)
|
||||
expect(readFileSync(canonicalConfigPath, 'utf-8')).toBe(fixture.config)
|
||||
})
|
||||
|
||||
it('rewrites relative path config values when syncing into managed homes', async () => {
|
||||
const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
|
||||
writeFileSync(
|
||||
@@ -325,7 +482,15 @@ describe('CodexAccountService config sync', () => {
|
||||
|
||||
it('does not rewrite managed configs that already match canonical config', async () => {
|
||||
const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
|
||||
const canonicalConfig = 'approval_policy = "never"\nsandbox_mode = "danger-full-access"\n'
|
||||
const { escapeTomlString } = await import('../codex/config-toml-trust')
|
||||
const userHookKey = `${join(testState.fakeHomeDir, '.codex', 'user-hooks.json')}:stop:0:0`
|
||||
const canonicalConfig = [
|
||||
'approval_policy = "never"',
|
||||
'sandbox_mode = "danger-full-access"',
|
||||
`[hooks.state."${escapeTomlString(userHookKey)}"]`,
|
||||
'trusted_hash = "sha256:user-owned"',
|
||||
''
|
||||
].join('\n')
|
||||
writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8')
|
||||
const managedHomePath = createManagedHome(
|
||||
testState.userDataDir,
|
||||
@@ -564,6 +729,65 @@ describe('CodexAccountService config sync', () => {
|
||||
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not seed source-home hook trust when adding a self-contained account', async () => {
|
||||
vi.resetModules()
|
||||
let fixture: Awaited<ReturnType<typeof createCanonicalHookTrustFixture>>
|
||||
let readHookTrustEntries: typeof ReadHookTrustEntries
|
||||
const spawnMock = vi.fn(
|
||||
(_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
kill: () => void
|
||||
}
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = vi.fn()
|
||||
|
||||
const loginHome = options.env.CODEX_HOME
|
||||
expect(loginHome).toBeTruthy()
|
||||
const entries = readHookTrustEntries(join(loginHome!, 'config.toml'))
|
||||
for (const key of fixture.orcaKeys) {
|
||||
expect(entries.has(key)).toBe(false)
|
||||
}
|
||||
expect(entries.has(fixture.userKey)).toBe(false)
|
||||
writeFileSync(
|
||||
join(loginHome!, 'auth.json'),
|
||||
createCodexAuthJson('user@example.com', 'provider-account-1', 'refresh-token'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
queueMicrotask(() => child.emit('close', 0))
|
||||
return child
|
||||
}
|
||||
)
|
||||
|
||||
vi.doMock('node:child_process', () => ({
|
||||
execFileSync: vi.fn(),
|
||||
spawn: spawnMock
|
||||
}))
|
||||
vi.doMock('../codex-cli/command', () => ({
|
||||
resolveCodexCommand: () => 'codex'
|
||||
}))
|
||||
fixture = await createCanonicalHookTrustFixture()
|
||||
readHookTrustEntries = (await import('../codex/config-toml-trust')).readHookTrustEntries
|
||||
writeFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), fixture.config, 'utf-8')
|
||||
|
||||
const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true }))
|
||||
const rateLimits = createRateLimits()
|
||||
const runtimeHome = createRuntimeHome()
|
||||
const { CodexAccountService } = await import('./service')
|
||||
const service = new CodexAccountService(
|
||||
store as never,
|
||||
rateLimits as never,
|
||||
runtimeHome as never
|
||||
)
|
||||
|
||||
await service.addAccount()
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('recreates the expected missing managed home before reauthenticating', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ main-process module so the managed-account boundary stays explicit. */
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||||
import { dirname, join, resolve, sep } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { app } from 'electron'
|
||||
import { getSpawnArgsForWindows } from '../win32-utils'
|
||||
@@ -16,6 +16,12 @@ import type {
|
||||
import type { CodexRuntimeHomeService } from './runtime-home-service'
|
||||
import { writeFileAtomically } from './fs-utils'
|
||||
import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite'
|
||||
import { stripCodexManagedHookTrustEntriesFromConfig } from '../codex/codex-managed-trust-reconciliation'
|
||||
import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag'
|
||||
import { getCodexManagedHookInstallMaterial } from '../codex/hook-service'
|
||||
import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror'
|
||||
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
|
||||
import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils'
|
||||
import { resolveCodexCommand } from '../codex-cli/command'
|
||||
import type { Store } from '../persistence'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
@@ -37,6 +43,7 @@ import {
|
||||
setSelectedCodexAccountIdForTarget,
|
||||
type CodexAccountSelectionTarget
|
||||
} from './runtime-selection'
|
||||
import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership'
|
||||
|
||||
const LOGIN_TIMEOUT_MS = 120_000
|
||||
const MAX_LOGIN_OUTPUT_CHARS = 4_000
|
||||
@@ -58,6 +65,7 @@ type CanonicalCodexConfig = {
|
||||
/** Home the config was read from, in the path style Codex sees at runtime
|
||||
* (Linux-side for WSL); relative path-valued settings resolve against it. */
|
||||
sourceHomePath: string
|
||||
sourceHooksPath: string
|
||||
}
|
||||
|
||||
export type CodexAccountAddTarget = {
|
||||
@@ -453,6 +461,15 @@ export class CodexAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
private isSelfContainedHostManagedHome(managedHomePath: string): boolean {
|
||||
// Why: flag ON makes each host account home its own launch CODEX_HOME. WSL
|
||||
// homes keep their distro-local seed lane; the flag-OFF opt-out is unchanged.
|
||||
return (
|
||||
isCodexSystemDefaultRealHomeEnabled(this.store.getSettings()) &&
|
||||
!parseWslUncPath(managedHomePath)
|
||||
)
|
||||
}
|
||||
|
||||
private syncCanonicalConfigIntoManagedHome(
|
||||
managedHomePath: string,
|
||||
canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath)
|
||||
@@ -462,15 +479,38 @@ export class CodexAccountService {
|
||||
}
|
||||
|
||||
const trustedManagedHomePath = this.assertManagedHomePath(managedHomePath)
|
||||
if (this.isSelfContainedHostManagedHome(trustedManagedHomePath)) {
|
||||
// Why: this home is codex's live CODEX_HOME, so mirror config with the
|
||||
// trust-preserving merge — the plain overwrite below would wipe the
|
||||
// hook/project trust codex granted in this home, forcing a re-approval and
|
||||
// an app-server re-grant on every account switch.
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath: trustedManagedHomePath,
|
||||
systemHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
return
|
||||
}
|
||||
// Why: Orca account switching is meant to swap Codex credentials and quota
|
||||
// identity, not silently fork the user's sandbox/config defaults. Syncing
|
||||
// one canonical config into every managed home keeps auth isolated per
|
||||
// account while preserving consistent Codex behavior. Managed homes are
|
||||
// real CODEX_HOMEs for `codex login`, so relative path-valued settings
|
||||
// must keep resolving against the home the config was read from.
|
||||
let sanitizedConfig = canonicalConfig.contents
|
||||
if (isCodexSystemDefaultRealHomeEnabled(this.store.getSettings())) {
|
||||
const material = getCodexManagedHookInstallMaterial()
|
||||
// Why: source-home Orca trust is foreign to each managed home's hooks.json.
|
||||
sanitizedConfig = stripCodexManagedHookTrustEntriesFromConfig(canonicalConfig.contents, {
|
||||
runtimeHomePath: canonicalConfig.sourceHomePath,
|
||||
sourcePath: canonicalConfig.sourceHooksPath,
|
||||
command: material.command,
|
||||
managedEventLabels: new Set(Object.values(material.eventLabel)),
|
||||
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
|
||||
})
|
||||
}
|
||||
this.writeManagedConfig(
|
||||
trustedManagedHomePath,
|
||||
rewriteRelativePathConfigValues(canonicalConfig.contents, canonicalConfig.sourceHomePath)
|
||||
rewriteRelativePathConfigValues(sanitizedConfig, canonicalConfig.sourceHomePath)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -482,7 +522,11 @@ export class CodexAccountService {
|
||||
}
|
||||
|
||||
try {
|
||||
return { contents: readFileSync(primaryConfigPath, 'utf-8'), sourceHomePath }
|
||||
return {
|
||||
contents: readFileSync(primaryConfigPath, 'utf-8'),
|
||||
sourceHomePath,
|
||||
sourceHooksPath: join(sourceHomePath, 'hooks.json')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to read canonical config:', error)
|
||||
return null
|
||||
@@ -509,7 +553,11 @@ export class CodexAccountService {
|
||||
try {
|
||||
// Why: the config is read over UNC but consumed by Codex inside WSL, so
|
||||
// path rewrites must anchor to the Linux-side ~/.codex, not the UNC path.
|
||||
return { contents: readFileSync(configPath, 'utf-8'), sourceHomePath: `${wslHome}/.codex` }
|
||||
return {
|
||||
contents: readFileSync(configPath, 'utf-8'),
|
||||
sourceHomePath: `${wslHome}/.codex`,
|
||||
sourceHooksPath: `${wslHome}/.codex/hooks.json`
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to read WSL canonical config:', error)
|
||||
return null
|
||||
@@ -679,47 +727,11 @@ export class CodexAccountService {
|
||||
return candidatePath
|
||||
}
|
||||
|
||||
const rootPath = this.getManagedAccountsRoot()
|
||||
const resolvedCandidate = resolve(candidatePath)
|
||||
const resolvedRoot = resolve(rootPath)
|
||||
|
||||
if (!existsSync(resolvedCandidate)) {
|
||||
throw new Error('Managed Codex home directory does not exist on disk.')
|
||||
}
|
||||
|
||||
// realpath() requires the leaf to exist. For pre-login add flow we create
|
||||
// the home directory first so the containment check still verifies the
|
||||
// canonical on-disk target rather than trusting persisted text blindly.
|
||||
const canonicalCandidate = realpathSync(resolvedCandidate)
|
||||
const canonicalRoot = realpathSync(resolvedRoot)
|
||||
|
||||
// Why: the prefix check must compare canonical paths on both sides. On
|
||||
// macOS, userData sits under /var/folders/... which realpath resolves to
|
||||
// /private/var/folders/...; comparing a canonical candidate against a
|
||||
// non-canonical root would spuriously reject every managed home. In dev
|
||||
// mode (orca-dev/ vs orca/) this check also filters out production-rooted
|
||||
// paths before downstream sync runs.
|
||||
if (
|
||||
canonicalCandidate !== canonicalRoot &&
|
||||
!canonicalCandidate.startsWith(canonicalRoot + sep)
|
||||
) {
|
||||
throw new Error(
|
||||
`Managed Codex home is outside current storage root (expected under ${canonicalRoot}).`
|
||||
)
|
||||
}
|
||||
const relativePath = relative(canonicalRoot, canonicalCandidate)
|
||||
const escaped =
|
||||
relativePath === '' || relativePath.startsWith('..') || relativePath.includes(`..${sep}`)
|
||||
|
||||
if (escaped) {
|
||||
throw new Error('Managed Codex home escaped Orca account storage.')
|
||||
}
|
||||
|
||||
if (!existsSync(join(canonicalCandidate, '.orca-managed-home'))) {
|
||||
throw new Error('Managed Codex home is missing Orca ownership marker.')
|
||||
}
|
||||
|
||||
return canonicalCandidate
|
||||
return assertOwnedHostCodexManagedHomePath({
|
||||
candidatePath,
|
||||
managedAccountsRoot: this.getManagedAccountsRoot(),
|
||||
systemCodexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
}
|
||||
|
||||
private safeRemoveWslManagedHomeCandidate(
|
||||
|
||||
@@ -182,6 +182,23 @@ describe('syncSystemCodexResourcesIntoManagedHome', () => {
|
||||
expect(existsSync(join(getRuntimeCodexHomePath(), 'history.jsonl'))).toBe(false)
|
||||
})
|
||||
|
||||
it('mirrors resources into an explicit per-account home without mutating ~/.codex', () => {
|
||||
mkdirSync(join(getSystemCodexHomePath(), 'skills', 'review'), { recursive: true })
|
||||
writeFileSync(join(getSystemCodexHomePath(), 'skills', 'review', 'SKILL.md'), 'skill\n')
|
||||
const perAccountHome = join(userDataDir, 'codex-accounts', 'account-1', 'home')
|
||||
mkdirSync(perAccountHome, { recursive: true })
|
||||
|
||||
syncSystemCodexResourcesIntoManagedHome(perAccountHome)
|
||||
|
||||
const perAccountSkillsPath = join(perAccountHome, 'skills')
|
||||
expect(readFileSync(join(perAccountSkillsPath, 'review', 'SKILL.md'), 'utf-8')).toBe('skill\n')
|
||||
expectSymbolicLinkTargetIfLinked(perAccountSkillsPath, join(getSystemCodexHomePath(), 'skills'))
|
||||
// The shared runtime mirror is untouched by an explicit per-account sync.
|
||||
expect(existsSync(join(getRuntimeCodexHomePath(), 'skills'))).toBe(false)
|
||||
// ~/.codex gains nothing — resources are only read from it, never written back.
|
||||
expect(existsSync(join(getSystemCodexHomePath(), 'codex-accounts'))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not replace an existing runtime-owned resource entry', () => {
|
||||
mkdirSync(join(getSystemCodexHomePath(), 'skills'), { recursive: true })
|
||||
mkdirSync(join(getRuntimeCodexHomePath(), 'skills'), { recursive: true })
|
||||
|
||||
@@ -57,11 +57,15 @@ function getOrcaUserDataPath(): string {
|
||||
return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'orca')
|
||||
}
|
||||
|
||||
export function syncSystemCodexResourcesIntoManagedHome(): void {
|
||||
// Why: each managed home (the shared runtime mirror, or a per-account
|
||||
// self-contained CODEX_HOME that the caller has already created) links the same
|
||||
// system resources with its own ownership markers, so a per-account launch home
|
||||
// is complete without ever symlinking into or mutating the user's real ~/.codex.
|
||||
export function syncSystemCodexResourcesIntoManagedHome(managedHomePath?: string): void {
|
||||
const targetHome = managedHomePath ?? getOrcaManagedCodexHomePath()
|
||||
const systemHomePath = getSystemCodexHomePath()
|
||||
const managedHomePath = getOrcaManagedCodexHomePath()
|
||||
for (const entryName of CODEX_SYSTEM_RESOURCE_ENTRIES) {
|
||||
linkSystemCodexResource(systemHomePath, managedHomePath, entryName)
|
||||
linkSystemCodexResource(systemHomePath, targetHome, entryName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
normalizeCodexHookSourcePath,
|
||||
parseTrustKey,
|
||||
readHookTrustEntries,
|
||||
readHookTrustEntriesFromContent,
|
||||
removeHookTrustEntries,
|
||||
removeHookTrustEntriesFromContent,
|
||||
type CodexEventLabel,
|
||||
type CodexHookTrustState,
|
||||
type CodexTrustEntry
|
||||
} from './config-toml-trust'
|
||||
import { getCodexHookTrustSignature } from './codex-hook-identity'
|
||||
@@ -52,8 +55,7 @@ function addLedgerRecognizedHashes(
|
||||
}
|
||||
}
|
||||
|
||||
export function removeCodexManagedHookTrustEntries(options: {
|
||||
tomlPath: string
|
||||
type CodexManagedHookTrustOwnershipOptions = {
|
||||
runtimeHomePath: string
|
||||
sourcePath: string
|
||||
command: string
|
||||
@@ -61,8 +63,12 @@ export function removeCodexManagedHookTrustEntries(options: {
|
||||
timeoutSec: number
|
||||
/** Explicit native homes resolve their parent before hook discovery. */
|
||||
sourceUsesExplicitCodexHome?: boolean
|
||||
}): void {
|
||||
const existingEntries = readHookTrustEntries(options.tomlPath)
|
||||
}
|
||||
|
||||
function getCodexManagedHookTrustEntryKeys(
|
||||
existingEntries: ReadonlyMap<string, CodexHookTrustState>,
|
||||
options: CodexManagedHookTrustOwnershipOptions
|
||||
): string[] {
|
||||
const ledgerHome = readCodexTrustGrantLedgerHomeForReconciliation(options.runtimeHomePath)
|
||||
const expectedSourcePath = options.sourceUsesExplicitCodexHome
|
||||
? getCodexExplicitHomeHookSourcePath(options.sourcePath)
|
||||
@@ -94,6 +100,27 @@ export function removeCodexManagedHookTrustEntries(options: {
|
||||
ownedKeys.push(key)
|
||||
}
|
||||
}
|
||||
return ownedKeys
|
||||
}
|
||||
|
||||
export function stripCodexManagedHookTrustEntriesFromConfig(
|
||||
contents: string,
|
||||
options: CodexManagedHookTrustOwnershipOptions
|
||||
): string {
|
||||
const ownedKeys = getCodexManagedHookTrustEntryKeys(
|
||||
readHookTrustEntriesFromContent(contents),
|
||||
options
|
||||
)
|
||||
return removeHookTrustEntriesFromContent(contents, ownedKeys)
|
||||
}
|
||||
|
||||
export function removeCodexManagedHookTrustEntries(
|
||||
options: CodexManagedHookTrustOwnershipOptions & { tomlPath: string }
|
||||
): void {
|
||||
const ownedKeys = getCodexManagedHookTrustEntryKeys(
|
||||
readHookTrustEntries(options.tomlPath),
|
||||
options
|
||||
)
|
||||
if (ownedKeys.length > 0) {
|
||||
removeHookTrustEntries(options.tomlPath, ownedKeys)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
normalizeCodexProjectPathForRevocationLookup,
|
||||
parseTrustKey,
|
||||
readHookTrustEntries,
|
||||
readHookTrustEntriesFromContent,
|
||||
removeHookTrustEntries,
|
||||
removeHookTrustEntriesFromContent,
|
||||
upsertHookTrustEntries,
|
||||
upsertProjectTrustLevel,
|
||||
upsertProjectTrustLevelInContent,
|
||||
@@ -1664,6 +1666,70 @@ describe('readHookTrustEntries', () => {
|
||||
expect(result.get(keyB)?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed when normalized duplicate blocks have conflicting hashes', () => {
|
||||
const key = '/x/hooks.json:stop:0:0'
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
`[hooks.state."${key}"]`,
|
||||
'trusted_hash = "sha256:USER"',
|
||||
'',
|
||||
`[hooks.state.'${key}']`,
|
||||
'trusted_hash = "sha256:ORCA"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(readHookTrustEntries(configPath).get(key)?.trustedHash).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores trust-looking fields inside multiline strings', () => {
|
||||
const key = '/x/hooks.json:stop:0:0'
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
`[hooks.state."${key}"]`,
|
||||
'note = """',
|
||||
'trusted_hash = "sha256:NOT-A-FIELD"',
|
||||
'enabled = false',
|
||||
'"""',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(readHookTrustEntries(configPath).get(key)).toEqual({
|
||||
trustedHash: undefined,
|
||||
enabled: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('does not accept an unterminated trusted_hash string', () => {
|
||||
const key = '/x/hooks.json:stop:0:0'
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`[hooks.state."${key}"]\ntrusted_hash = "sha256:UNTERMINATED\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(readHookTrustEntries(configPath).get(key)?.trustedHash).toBeUndefined()
|
||||
})
|
||||
|
||||
it('recognizes and removes a first trust block after a leading BOM', () => {
|
||||
const key = '/x/hooks.json:stop:0:0'
|
||||
const content = [
|
||||
`\uFEFF[hooks.state."${key}"]`,
|
||||
'trusted_hash = "sha256:ORCA"',
|
||||
'[other]',
|
||||
'value = true',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
expect(readHookTrustEntriesFromContent(content).get(key)?.trustedHash).toBe('sha256:ORCA')
|
||||
expect(removeHookTrustEntriesFromContent(content, [key])).toBe('[other]\nvalue = true\n')
|
||||
})
|
||||
|
||||
it('does not let triple quotes in comments hide later trust entries', () => {
|
||||
const key = '/x/hooks.json:pre_tool_use:0:0'
|
||||
const original = [
|
||||
|
||||
@@ -599,18 +599,34 @@ export function normalizeHookTrustKeyForLookup(key: string): string {
|
||||
}
|
||||
|
||||
function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
|
||||
return findTrustBlockRangesForNormalizedKeys(
|
||||
content,
|
||||
new Set([normalizeHookTrustKeyForLookup(key)])
|
||||
)
|
||||
}
|
||||
|
||||
function findTrustBlockRangesForNormalizedKeys(
|
||||
content: string,
|
||||
normalizedKeys: ReadonlySet<string>
|
||||
): TrustBlockRange[] {
|
||||
const ranges: TrustBlockRange[] = []
|
||||
const normalizedKey = normalizeHookTrustKeyForLookup(key)
|
||||
if (normalizedKeys.size === 0) {
|
||||
return ranges
|
||||
}
|
||||
let cursor = 0
|
||||
let scanState = createTomlLineScanState()
|
||||
while (cursor < content.length) {
|
||||
const newlineIdx = content.indexOf('\n', cursor)
|
||||
const lineEnd = newlineIdx === -1 ? content.length : newlineIdx
|
||||
const rawLine = content.slice(cursor, lineEnd)
|
||||
const line = rawLine.replace(/\r$/, '')
|
||||
const lineWithoutCr = rawLine.replace(/\r$/, '')
|
||||
const line =
|
||||
cursor === 0 && lineWithoutCr.charCodeAt(0) === 0xfeff
|
||||
? lineWithoutCr.slice(1)
|
||||
: lineWithoutCr
|
||||
const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1
|
||||
const headerKey = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null
|
||||
if (headerKey !== null && normalizeHookTrustKeyForLookup(headerKey) === normalizedKey) {
|
||||
if (headerKey !== null && normalizedKeys.has(normalizeHookTrustKeyForLookup(headerKey))) {
|
||||
const headerLineEnd = rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd
|
||||
const after = content.slice(headerLineEnd)
|
||||
const nextHeaderRel = findNextTableHeader(after)
|
||||
@@ -876,18 +892,19 @@ export function removeHookTrustEntries(configPath: string, keys: readonly string
|
||||
return
|
||||
}
|
||||
const existing = readTomlFile(configPath)
|
||||
let updated = existing
|
||||
for (const key of keys) {
|
||||
updated = removeTrustBlock(updated, key)
|
||||
}
|
||||
const updated = removeHookTrustEntriesFromContent(existing, keys)
|
||||
if (updated === existing) {
|
||||
return
|
||||
}
|
||||
writeConfigAtomically(configPath, updated)
|
||||
}
|
||||
|
||||
function removeTrustBlock(content: string, key: string): string {
|
||||
const ranges = findTrustBlockRanges(content, key)
|
||||
export function removeHookTrustEntriesFromContent(
|
||||
content: string,
|
||||
keys: readonly string[]
|
||||
): string {
|
||||
const normalizedKeys = new Set(keys.map(normalizeHookTrustKeyForLookup))
|
||||
const ranges = findTrustBlockRangesForNormalizedKeys(content, normalizedKeys)
|
||||
if (ranges.length === 0) {
|
||||
return content
|
||||
}
|
||||
@@ -902,11 +919,45 @@ function removeTrustBlock(content: string, key: string): string {
|
||||
}
|
||||
|
||||
export function readHookTrustEntries(configPath: string): Map<string, CodexHookTrustState> {
|
||||
const result = new HookTrustEntryMap()
|
||||
if (!existsSync(configPath)) {
|
||||
return result
|
||||
return new HookTrustEntryMap()
|
||||
}
|
||||
const content = readTomlFile(configPath)
|
||||
return readHookTrustEntriesFromContent(readTomlFile(configPath))
|
||||
}
|
||||
|
||||
function readHookTrustBlockState(block: string): {
|
||||
trustedHashes: Set<string>
|
||||
enabled?: boolean
|
||||
} {
|
||||
const trustedHashes = new Set<string>()
|
||||
let enabled: boolean | undefined
|
||||
let cursor = 0
|
||||
let scanState = createTomlLineScanState()
|
||||
while (cursor < block.length) {
|
||||
const newlineIdx = block.indexOf('\n', cursor)
|
||||
const lineEnd = newlineIdx === -1 ? block.length : newlineIdx
|
||||
const line = block.slice(cursor, lineEnd).replace(/\r$/, '')
|
||||
if (isTomlStructuralLine(scanState)) {
|
||||
const hashMatch = /^[ \t]*trusted_hash[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"[ \t]*(?:#.*)?$/.exec(
|
||||
line
|
||||
)
|
||||
if (hashMatch) {
|
||||
trustedHashes.add(unescapeTomlString(hashMatch[1]))
|
||||
}
|
||||
const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t]*(?:#.*)?$/.exec(line)
|
||||
if (enabledMatch) {
|
||||
enabled = enabled !== false && enabledMatch[1] === 'true'
|
||||
}
|
||||
}
|
||||
scanState = updateTomlLineScanState(scanState, line)
|
||||
cursor = newlineIdx === -1 ? block.length : newlineIdx + 1
|
||||
}
|
||||
return { trustedHashes, enabled }
|
||||
}
|
||||
|
||||
export function readHookTrustEntriesFromContent(content: string): Map<string, CodexHookTrustState> {
|
||||
const result = new HookTrustEntryMap()
|
||||
const conflictingTrustedHashKeys = new Set<string>()
|
||||
// Why: walk line-by-line so `[hooks.state."..."]` inside a `"""..."""` or
|
||||
// `'''...'''` multi-line string isn't mistaken for a real header.
|
||||
let cursor = 0
|
||||
@@ -915,7 +966,11 @@ export function readHookTrustEntries(configPath: string): Map<string, CodexHookT
|
||||
const newlineIdx = content.indexOf('\n', cursor)
|
||||
const lineEnd = newlineIdx === -1 ? content.length : newlineIdx
|
||||
const rawLine = content.slice(cursor, lineEnd)
|
||||
const line = rawLine.replace(/\r$/, '')
|
||||
const lineWithoutCr = rawLine.replace(/\r$/, '')
|
||||
const line =
|
||||
cursor === 0 && lineWithoutCr.charCodeAt(0) === 0xfeff
|
||||
? lineWithoutCr.slice(1)
|
||||
: lineWithoutCr
|
||||
const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1
|
||||
const key = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null
|
||||
if (key !== null) {
|
||||
@@ -924,21 +979,33 @@ export function readHookTrustEntries(configPath: string): Map<string, CodexHookT
|
||||
const nextHeaderRel = findNextTableHeader(after)
|
||||
const blockEnd = nextHeaderRel === -1 ? content.length : nextCursor + nextHeaderRel
|
||||
const block = content.slice(nextCursor, blockEnd)
|
||||
// Why: we own this block's shape (only `enabled` + `trusted_hash`), so
|
||||
// a line scan beats pulling in a full TOML value parser.
|
||||
const hashMatch = /^[ \t]*trusted_hash[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"/m.exec(block)
|
||||
const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(block)
|
||||
const blockState = readHookTrustBlockState(block)
|
||||
const normalizedKey = normalizeHookTrustKeyForLookup(key)
|
||||
const existingState = result.get(normalizedKey)
|
||||
const enabled = enabledMatch ? enabledMatch[1] === 'true' : undefined
|
||||
const trustedHash =
|
||||
blockState.trustedHashes.size === 1
|
||||
? blockState.trustedHashes.values().next().value
|
||||
: undefined
|
||||
if (
|
||||
blockState.trustedHashes.size > 1 ||
|
||||
(trustedHash !== undefined &&
|
||||
existingState?.trustedHash !== undefined &&
|
||||
existingState.trustedHash !== trustedHash)
|
||||
) {
|
||||
// Why: conflicting normalized duplicates are malformed and cannot prove
|
||||
// which block is owned, so trust cleanup must preserve them all.
|
||||
conflictingTrustedHashKeys.add(normalizedKey)
|
||||
}
|
||||
result.set(normalizedKey, {
|
||||
trustedHash: hashMatch ? unescapeTomlString(hashMatch[1]) : existingState?.trustedHash,
|
||||
trustedHash: conflictingTrustedHashKeys.has(normalizedKey)
|
||||
? undefined
|
||||
: (trustedHash ?? existingState?.trustedHash),
|
||||
// Why: Windows writes both slash variants for one hook; a disabled copy
|
||||
// must remain authoritative regardless of which variant appears last.
|
||||
enabled:
|
||||
existingState?.enabled === false || enabled === false
|
||||
existingState?.enabled === false || blockState.enabled === false
|
||||
? false
|
||||
: (enabled ?? existingState?.enabled)
|
||||
: (blockState.enabled ?? existingState?.enabled)
|
||||
})
|
||||
cursor = nextCursor
|
||||
continue
|
||||
|
||||
@@ -157,6 +157,33 @@ describe('CodexHookService', () => {
|
||||
expect(trustConfig).toContain(':permission_request:0:0')
|
||||
})
|
||||
|
||||
it('installs managed hooks + trust into a per-account self-contained home, not the shared mirror', () => {
|
||||
const systemCodexHome = join(tmpHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(join(systemCodexHome, 'config.toml'), 'approval_policy = "on-request"\n', 'utf-8')
|
||||
|
||||
const perAccountHome = join(userDataDir, 'codex-accounts', 'account-1', 'home')
|
||||
mkdirSync(perAccountHome, { recursive: true })
|
||||
writeFileSync(join(perAccountHome, '.orca-managed-home'), 'account-1\n', 'utf-8')
|
||||
|
||||
const status = new CodexHookService().install(perAccountHome)
|
||||
expect(status.state).toBe('installed')
|
||||
|
||||
// Hooks + trust land in THIS account's home.
|
||||
const hooksConfig = JSON.parse(readFileSync(join(perAccountHome, 'hooks.json'), 'utf-8')) as {
|
||||
hooks: Record<string, unknown>
|
||||
}
|
||||
expect(Object.keys(hooksConfig.hooks).sort()).toEqual(localManagedCodexEvents())
|
||||
const trustConfig = readFileSync(join(perAccountHome, 'config.toml'), 'utf-8')
|
||||
expect(trustConfig).toContain('approval_policy = "on-request"')
|
||||
expect(trustConfig).toContain(':permission_request:0:0')
|
||||
|
||||
// The shared runtime mirror is never touched by a per-account install.
|
||||
expect(existsSync(join(userDataDir, 'codex-runtime-home', 'home', 'hooks.json'))).toBe(false)
|
||||
// ~/.codex is only read for canonical config, never mutated with hooks.
|
||||
expect(existsSync(join(systemCodexHome, 'hooks.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it('drops plugin manager metadata from runtime hooks.json during install', () => {
|
||||
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
|
||||
mkdirSync(managedCodexHome, { recursive: true })
|
||||
|
||||
@@ -95,8 +95,8 @@ const CODEX_EVENTS = [
|
||||
'Stop'
|
||||
] as const
|
||||
|
||||
function getConfigPath(): string {
|
||||
return join(getOrcaManagedCodexHomePath(), 'hooks.json')
|
||||
function getConfigPath(runtimeHomePath: string = getOrcaManagedCodexHomePath()): string {
|
||||
return join(runtimeHomePath, 'hooks.json')
|
||||
}
|
||||
|
||||
function writeCodexHooksJson(configPath: string, hooks: Record<string, HookDefinition[]>): void {
|
||||
@@ -105,8 +105,8 @@ function writeCodexHooksJson(configPath: string, hooks: Record<string, HookDefin
|
||||
writeHooksJson(configPath, { hooks })
|
||||
}
|
||||
|
||||
function getCodexConfigTomlPath(): string {
|
||||
return join(getOrcaManagedCodexHomePath(), 'config.toml')
|
||||
function getCodexConfigTomlPath(runtimeHomePath: string = getOrcaManagedCodexHomePath()): string {
|
||||
return join(runtimeHomePath, 'config.toml')
|
||||
}
|
||||
|
||||
// Why: the managed-event subset of the shared PascalCase→label map; the
|
||||
@@ -297,14 +297,14 @@ function removeCodexPluginEnvironmentCommands(definitions: HookDefinition[]): Ho
|
||||
|
||||
function getRuntimeHooksWithSystemUserHooks(
|
||||
runtimeHooks: Record<string, HookDefinition[]> | undefined,
|
||||
isManagedCommand: (command: string | undefined) => boolean
|
||||
isManagedCommand: (command: string | undefined) => boolean,
|
||||
runtimeConfigPath: string = getConfigPath()
|
||||
): {
|
||||
hooks: Record<string, HookDefinition[]>
|
||||
trustEntries: MirroredRuntimeUserHookTrustEntry[]
|
||||
} {
|
||||
const systemConfigPath = getSystemConfigPath()
|
||||
const runtimeConfigPath = getConfigPath()
|
||||
if (systemConfigPath === getConfigPath()) {
|
||||
if (systemConfigPath === runtimeConfigPath) {
|
||||
return { hooks: { ...runtimeHooks }, trustEntries: [] }
|
||||
}
|
||||
|
||||
@@ -1140,14 +1140,15 @@ export class CodexHookService {
|
||||
return wslPlan ? refreshWslRuntimeUserHooks(wslPlan) : null
|
||||
}
|
||||
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
return this.getStatusAfterInstall(null)
|
||||
getStatus(runtimeHomePath: string = getOrcaManagedCodexHomePath()): AgentHookInstallStatus {
|
||||
return this.getStatusAfterInstall(null, runtimeHomePath)
|
||||
}
|
||||
|
||||
private getStatusAfterInstall(
|
||||
recentGrantEntries: readonly CodexTrustEntry[] | null
|
||||
recentGrantEntries: readonly CodexTrustEntry[] | null,
|
||||
runtimeHomePath: string = getOrcaManagedCodexHomePath()
|
||||
): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const configPath = getConfigPath(runtimeHomePath)
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
@@ -1164,7 +1165,7 @@ export class CodexHookService {
|
||||
// trust entries are missing/stale. Codex 0.129+ silently drops untrusted
|
||||
// hooks, so a green status without trust verification is misleading.
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const tomlPath = getCodexConfigTomlPath()
|
||||
const tomlPath = getCodexConfigTomlPath(runtimeHomePath)
|
||||
// Why: an unreadable config.toml (EACCES/EIO) is distinct from "file
|
||||
// absent" (which returns an empty Map without throwing). Hooks.json may
|
||||
// still be fine, so report partial with a specific reason rather than
|
||||
@@ -1184,7 +1185,7 @@ export class CodexHookService {
|
||||
// hashes or wrote fallback hashes. Re-resolving PATH here doubles sync launch work.
|
||||
const ledgerHome =
|
||||
recentGrantEntries === null
|
||||
? readCurrentCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath(), { kind: 'native' })
|
||||
? readCurrentCodexTrustGrantLedgerHome(runtimeHomePath, { kind: 'native' })
|
||||
: null
|
||||
const recentGrantHashes = new Map<string, { signature: string; trustedHash: string }>()
|
||||
for (const entry of recentGrantEntries ?? []) {
|
||||
@@ -1295,14 +1296,17 @@ export class CodexHookService {
|
||||
return { agent: 'codex', state, configPath, managedHooksPresent, detail }
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
// Why: runtimeHomePath defaults to the shared managed mirror, but a managed
|
||||
// account launching against its own self-contained CODEX_HOME passes that
|
||||
// per-account home so hooks.json/config.toml/trust land where codex reads.
|
||||
install(runtimeHomePath: string = getOrcaManagedCodexHomePath()): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath(runtimeHomePath)
|
||||
const scriptPath = getManagedScriptPath()
|
||||
// Why: must run before this install rewrites hooks.json/config.toml —
|
||||
// approvals the user made inside Orca-launched Codex are keyed to the
|
||||
// previous launch's runtime layout, and stale-trust cleanup below would
|
||||
// delete them once the system config stops backing them.
|
||||
promoteCodexRuntimeHookApprovalsToSystem()
|
||||
promoteCodexRuntimeHookApprovalsToSystem(runtimeHomePath)
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
@@ -1320,7 +1324,7 @@ export class CodexHookService {
|
||||
// accumulate duplicate hook entries pointing at defunct scripts.
|
||||
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand)
|
||||
const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand, configPath)
|
||||
const nextHooks = hookPlan.hooks
|
||||
const managedEvents = new Set<string>(CODEX_EVENTS)
|
||||
|
||||
@@ -1390,8 +1394,11 @@ export class CodexHookService {
|
||||
// pointing at a hook that doesn't exist. Surface failures — without this,
|
||||
// getStatus would report green for a hook Codex won't actually fire.
|
||||
try {
|
||||
const tomlPath = getCodexConfigTomlPath()
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
const tomlPath = getCodexConfigTomlPath(runtimeHomePath)
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath,
|
||||
systemHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
// Why: Codex is the only authority on its trust-hash algorithm, so the
|
||||
// managed entries are granted through codex app-server RPCs (verified by
|
||||
// re-list) whenever the installed CLI supports them; the granted entries
|
||||
@@ -1399,7 +1406,7 @@ export class CodexHookService {
|
||||
// delete what Codex just wrote. Mirrored user trust keeps its existing
|
||||
// verbatim-carry lane either way.
|
||||
const grant = grantManagedCodexHookTrust({
|
||||
runtimeHomePath: getOrcaManagedCodexHomePath(),
|
||||
runtimeHomePath,
|
||||
tomlPath,
|
||||
managedCommand: command,
|
||||
managedEntries: managedTrustEntries,
|
||||
@@ -1431,14 +1438,14 @@ export class CodexHookService {
|
||||
detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.`
|
||||
}
|
||||
}
|
||||
snapshotCodexRuntimeHookTrustProvenance()
|
||||
snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath)
|
||||
try {
|
||||
cleanupLegacySystemManagedHooks()
|
||||
cleanupLegacyCodexProfileHooks()
|
||||
} catch (error) {
|
||||
console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error)
|
||||
}
|
||||
return this.getStatusAfterInstall(recentGrantEntries)
|
||||
return this.getStatusAfterInstall(recentGrantEntries, runtimeHomePath)
|
||||
}
|
||||
|
||||
async installRemote(
|
||||
@@ -1563,11 +1570,13 @@ export class CodexHookService {
|
||||
}
|
||||
}
|
||||
|
||||
refreshRuntimeUserHooks(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
refreshRuntimeUserHooks(
|
||||
runtimeHomePath: string = getOrcaManagedCodexHomePath()
|
||||
): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath(runtimeHomePath)
|
||||
// Why: same as install() — capture in-Orca approvals before this refresh
|
||||
// rewrites the runtime files they are keyed against.
|
||||
promoteCodexRuntimeHookApprovalsToSystem()
|
||||
promoteCodexRuntimeHookApprovalsToSystem(runtimeHomePath)
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
// Why: disabled launch prep used to call remove(); preserve its legacy
|
||||
@@ -1583,14 +1592,17 @@ export class CodexHookService {
|
||||
}
|
||||
|
||||
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
|
||||
const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand)
|
||||
const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand, configPath)
|
||||
config.hooks = hookPlan.hooks
|
||||
writeCodexHooksJson(configPath, hookPlan.hooks)
|
||||
|
||||
try {
|
||||
const tomlPath = getCodexConfigTomlPath()
|
||||
const tomlPath = getCodexConfigTomlPath(runtimeHomePath)
|
||||
const trustEntries = hookPlan.trustEntries.map(({ entry }) => entry)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath,
|
||||
systemHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
// Why: this path is used when Orca status hooks are disabled. The
|
||||
// runtime CODEX_HOME should keep user hooks, but not Orca-managed trust.
|
||||
// Write current mirrored user trust first so stale cleanup compares
|
||||
@@ -1607,10 +1619,10 @@ export class CodexHookService {
|
||||
detail: `User hooks refreshed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.`
|
||||
}
|
||||
}
|
||||
snapshotCodexRuntimeHookTrustProvenance()
|
||||
snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath)
|
||||
|
||||
cleanupLegacyManagedHookRepresentations()
|
||||
return this.getStatus()
|
||||
return this.getStatus(runtimeHomePath)
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
|
||||
@@ -78,9 +78,10 @@ function readHookTrustProvenance(
|
||||
* install/refresh, so the next launch can tell "entry Orca wrote" apart from
|
||||
* "entry Codex wrote after a user approval". Call after all trust writes.
|
||||
*/
|
||||
export function snapshotCodexRuntimeHookTrustProvenance(): void {
|
||||
export function snapshotCodexRuntimeHookTrustProvenance(
|
||||
runtimeHomePath: string = getOrcaManagedCodexHomePath()
|
||||
): void {
|
||||
try {
|
||||
const runtimeHomePath = getOrcaManagedCodexHomePath()
|
||||
const runtimeHooksPath = join(runtimeHomePath, 'hooks.json')
|
||||
const canonicalRuntimeHooksPath = getCodexExplicitHomeHookSourcePath(runtimeHooksPath)
|
||||
const entries: Record<string, HookTrustProvenanceEntry> = {}
|
||||
@@ -115,9 +116,11 @@ export function snapshotCodexRuntimeHookTrustProvenance(): void {
|
||||
* the user's own hooks.json. Runs before the config mirror so the promoted
|
||||
* trust is mirrored back on the same launch.
|
||||
*/
|
||||
export function promoteCodexRuntimeHookApprovalsToSystem(): void {
|
||||
export function promoteCodexRuntimeHookApprovalsToSystem(
|
||||
runtimeHomePath: string = getOrcaManagedCodexHomePath()
|
||||
): void {
|
||||
try {
|
||||
promoteCodexRuntimeHookApprovalsToSystemUnsafe()
|
||||
promoteCodexRuntimeHookApprovalsToSystemUnsafe(runtimeHomePath)
|
||||
} catch (error) {
|
||||
// Why: promotion is best-effort launch prep; a malformed runtime file
|
||||
// must not block hook install or the Codex launch itself.
|
||||
@@ -125,8 +128,7 @@ export function promoteCodexRuntimeHookApprovalsToSystem(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function promoteCodexRuntimeHookApprovalsToSystemUnsafe(): void {
|
||||
const runtimeHomePath = getOrcaManagedCodexHomePath()
|
||||
function promoteCodexRuntimeHookApprovalsToSystemUnsafe(runtimeHomePath: string): void {
|
||||
const systemHomePath = getSystemCodexHomePath()
|
||||
const runtimeHooksPath = join(runtimeHomePath, 'hooks.json')
|
||||
const systemHooksPath = join(systemHomePath, 'hooks.json')
|
||||
|
||||
@@ -1950,6 +1950,43 @@ describe('createPtySubprocess', () => {
|
||||
expect(env.ORCA_CODEX_HOME).toBeUndefined()
|
||||
})
|
||||
|
||||
it('strips an inherited per-account self-contained CODEX_HOME overlay in a nested Orca (#5370)', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const previousCodexHome = process.env.CODEX_HOME
|
||||
const previousOrcaCodexHome = process.env.ORCA_CODEX_HOME
|
||||
// A per-account home is injected as CODEX_HOME === ORCA_CODEX_HOME, so the
|
||||
// nested-Orca strip must clear it exactly as it does the shared mirror.
|
||||
const perAccountHome = '/daemon/managed/codex-accounts/019f0000-aaaa/home'
|
||||
process.env.CODEX_HOME = perAccountHome
|
||||
process.env.ORCA_CODEX_HOME = perAccountHome
|
||||
|
||||
try {
|
||||
createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: { SHELL: '/bin/bash' },
|
||||
envToDelete: ['ORCA_CODEX_HOME']
|
||||
})
|
||||
} finally {
|
||||
if (previousCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME
|
||||
} else {
|
||||
process.env.CODEX_HOME = previousCodexHome
|
||||
}
|
||||
if (previousOrcaCodexHome === undefined) {
|
||||
delete process.env.ORCA_CODEX_HOME
|
||||
} else {
|
||||
process.env.ORCA_CODEX_HOME = previousOrcaCodexHome
|
||||
}
|
||||
}
|
||||
|
||||
const env = spawnMock.mock.calls.at(-1)![2].env
|
||||
expect(env.CODEX_HOME).toBeUndefined()
|
||||
expect(env.ORCA_CODEX_HOME).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves a daemon-owned custom Codex home while deleting a stale private marker', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
+4
-2
@@ -792,9 +792,11 @@ function prepareCodexRuntimeHomeForLaunch(
|
||||
// the persisted off switch so those launches cannot reinstall removed hooks.
|
||||
const status = hooksEnabled
|
||||
? (codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget) ??
|
||||
codexHookService.install())
|
||||
// Why: a managed account's launch home is its own self-contained
|
||||
// CODEX_HOME, so hooks/trust must install there, not the shared mirror.
|
||||
codexHookService.install(runtimeHomePath ?? undefined))
|
||||
: (codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ??
|
||||
codexHookService.refreshRuntimeUserHooks())
|
||||
codexHookService.refreshRuntimeUserHooks(runtimeHomePath ?? undefined))
|
||||
if (status.state === 'error') {
|
||||
console.warn(
|
||||
`[codex-hook-service] failed to ${
|
||||
|
||||
Reference in New Issue
Block a user