feat(codex): give each managed account a self-contained CODEX_HOME; retire shared mirror (PR-E)

With the real-home flag ON, a host managed account now launches directly
against its own codex-accounts/<id>/home instead of the shared runtime
mirror + auth.json hot-swap:

- codex-home-paths: syncSystemCodexResourcesIntoManagedHome links system
  resources into any managed home (ownership-marker discipline; never
  symlinks into / mutates ~/.codex).
- runtime-home-service: prepareForCodexLaunch / prepareForRateLimitFetch /
  syncForCurrentSelection route the per-account home directly and skip the
  shared-home hot-swap + token read-back; each home keeps its own auth in
  place (fixes GAP-5 concurrent auth race). Session discovery scans every
  per-account home.
- hook-service / hook-trust-promotion: install/getStatus/refresh accept a
  runtimeHomePath so hooks + RPC-granted trust land in the per-account home.
- service: config mirror into a self-contained home uses the trust-
  preserving merge so granted hook/project trust survives account switches.
- codex-session-root-dedup: rank codex-accounts/<id>/home as canonical
  managed alongside the shared runtime home.

Flag-OFF and the system-default real-home (null) lane are unchanged; the
nested-Orca CODEX_HOME===ORCA_CODEX_HOME daemon strip (#5370) is preserved.
Sandboxed tests only; ~/.codex is never mutated.
This commit is contained in:
Brennan Benson
2026-07-17 03:17:08 -07:00
parent efaa61d596
commit c6ab5195e7
13 changed files with 527 additions and 46 deletions
@@ -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
}
/**
@@ -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 () => {
@@ -169,6 +169,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 +198,80 @@ 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 = account.managedHomePath
if (!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.syncSelfContainedManagedSelection(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 {
if (existsSync(join(account.managedHomePath, 'auth.json'))) {
this.lastSyncedAccountId = account.id
return
}
console.warn(
'[codex-runtime-home] Active managed account is missing auth.json, clearing selection'
)
const settings = this.store.getSettings()
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) {
@@ -238,6 +321,12 @@ export class CodexRuntimeHomeService {
// explicitly include the real lane so its sessions remain discoverable.
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 +433,16 @@ export class CodexRuntimeHomeService {
const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget)
return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
}
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
if (
selfContainedAccount &&
existsSync(join(selfContainedAccount.managedHomePath, '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 selfContainedAccount.managedHomePath
}
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 +462,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) {
+4
View File
@@ -48,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,
+23
View File
@@ -19,6 +19,9 @@ import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-refe
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 { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag'
import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils'
import { resolveCodexCommand } from '../codex-cli/command'
import type { Store } from '../persistence'
@@ -458,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)
@@ -467,6 +479,17 @@ 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
+17
View File
@@ -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 })
+7 -3
View File
@@ -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)
}
}
+27
View File
@@ -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 })
+42 -30
View File
@@ -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 {
+8 -6
View File
@@ -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')
+37
View File
@@ -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
View File
@@ -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 ${