fix(codex): validate per-account home ownership

This commit is contained in:
Brennan Benson
2026-07-17 03:19:37 -07:00
parent c6ab5195e7
commit 433356adcf
5 changed files with 240 additions and 58 deletions
@@ -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
}
+44 -10
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
@@ -230,11 +231,11 @@ export class CodexRuntimeHomeService {
}
private prepareSelfContainedManagedHomeForLaunch(account: CodexManagedAccount): string | null {
const perAccountHome = account.managedHomePath
if (!existsSync(join(perAccountHome, 'auth.json'))) {
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.syncSelfContainedManagedSelection(account)
this.clearSelfContainedManagedSelection(account)
return null
}
// Why: link the user's real ~/.codex resources and mirror config into THIS
@@ -254,14 +255,39 @@ export class CodexRuntimeHomeService {
// 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'))) {
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 is missing auth.json, clearing selection'
'[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: {
@@ -316,9 +342,10 @@ 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
@@ -434,14 +461,21 @@ export class CodexRuntimeHomeService {
return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
}
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
const selfContainedHome = selfContainedAccount
? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount)
: null
if (
selfContainedAccount &&
existsSync(join(selfContainedAccount.managedHomePath, 'auth.json'))
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 selfContainedAccount.managedHomePath
return selfContainedHome
}
if (selfContainedAccount) {
this.clearSelfContainedManagedSelection(selfContainedAccount)
}
if (this.isHostSystemDefaultRealHome()) {
// Why: null lets the fetcher fall back to the main process's inherited
+7 -5
View File
@@ -344,7 +344,7 @@ describe('CodexAccountService config sync', () => {
)
})
it('strips only Orca-owned source-home hook trust on startup and account selection', async () => {
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')
@@ -354,6 +354,7 @@ describe('CodexAccountService config sync', () => {
'approval_policy = "on-request"\n'
)
const settings = createSettings({
codexSystemDefaultRealHomeEnabled: true,
codexManagedAccounts: [
{
id: 'account-1',
@@ -385,7 +386,8 @@ describe('CodexAccountService config sync', () => {
for (const key of fixture.orcaKeys) {
expect(entries.has(key)).toBe(false)
}
expect(entries.has(fixture.userKey)).toBe(true)
// The launch-time hook mirror remaps user trust to this home's hooks.json.
expect(entries.has(fixture.userKey)).toBe(false)
}
expectSanitizedManagedConfig()
@@ -727,7 +729,7 @@ describe('CodexAccountService config sync', () => {
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1)
})
it('does not seed Orca-owned source-home trust when adding an account', async () => {
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
@@ -748,7 +750,7 @@ describe('CodexAccountService config sync', () => {
for (const key of fixture.orcaKeys) {
expect(entries.has(key)).toBe(false)
}
expect(entries.has(fixture.userKey)).toBe(true)
expect(entries.has(fixture.userKey)).toBe(false)
writeFileSync(
join(loginHome!, 'auth.json'),
createCodexAuthJson('user@example.com', 'provider-account-1', 'refresh-token'),
@@ -771,7 +773,7 @@ describe('CodexAccountService config sync', () => {
readHookTrustEntries = (await import('../codex/config-toml-trust')).readHookTrustEntries
writeFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), fixture.config, 'utf-8')
const store = createStore(createSettings())
const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true }))
const rateLimits = createRateLimits()
const runtimeHome = createRuntimeHome()
const { CodexAccountService } = await import('./service')
+7 -43
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'
@@ -21,7 +21,6 @@ import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-fl
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'
@@ -44,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
@@ -727,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(