merge(codex-ma): integrate PR-E per-account home (brings PR-A)

This commit is contained in:
Brennan Benson
2026-07-17 03:22:38 -07:00
18 changed files with 1186 additions and 120 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
}
/**
@@ -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 () => {
+144 -3
View File
@@ -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) {
+225 -1
View File
@@ -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()
+57 -45
View File
@@ -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(
+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)
}
}
@@ -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)
}
+66
View File
@@ -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 = [
+88 -21
View File
@@ -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
+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 ${