mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
fix(codex): validate per-account home ownership
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user