mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
fix(codex): preserve real-home lane invariants
This commit is contained in:
@@ -988,6 +988,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
|
||||
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
|
||||
expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()])
|
||||
expect(existsSync(getRuntimeCodexHomePath())).toBe(true)
|
||||
})
|
||||
|
||||
@@ -998,6 +999,23 @@ describe('CodexRuntimeHomeService', () => {
|
||||
|
||||
expect(service.isHostSystemDefaultRealHome()).toBe(true)
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([
|
||||
getRuntimeCodexHomePath(),
|
||||
getSystemCodexHomePath()
|
||||
])
|
||||
const perSpawnCustomHome = join(testState.fakeHomeDir, 'per-spawn-custom-codex-home')
|
||||
expect(service.isHostSystemDefaultRealHome({ CODEX_HOME: perSpawnCustomHome })).toBe(false)
|
||||
expect(service.prepareForCodexLaunch(undefined, { CODEX_HOME: perSpawnCustomHome })).toBe(
|
||||
getRuntimeCodexHomePath()
|
||||
)
|
||||
writeFileSync(
|
||||
join(testState.fakeHomeDir, '.zshrc'),
|
||||
'export CODEX_HOME="$HOME/shell-custom-codex-home"\n',
|
||||
'utf-8'
|
||||
)
|
||||
const shellLaunchEnv = { HOME: testState.fakeHomeDir, SHELL: '/bin/zsh' }
|
||||
expect(service.isHostSystemDefaultRealHome(shellLaunchEnv)).toBe(false)
|
||||
expect(service.prepareForCodexLaunch(undefined, shellLaunchEnv)).toBe(getRuntimeCodexHomePath())
|
||||
const previousCodexHome = process.env.CODEX_HOME
|
||||
const previousOrcaCodexHome = process.env.ORCA_CODEX_HOME
|
||||
process.env.CODEX_HOME = getRuntimeCodexHomePath()
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
import { getDefaultWslDistro, getWslHome } from '../wsl'
|
||||
import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag'
|
||||
import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path'
|
||||
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
|
||||
|
||||
type CodexAuthIdentity = {
|
||||
email: string | null
|
||||
@@ -93,6 +94,20 @@ type CodexReadBackMatch =
|
||||
}
|
||||
| { kind: 'none' | 'ambiguous' }
|
||||
|
||||
function readLaunchEnvValue(
|
||||
launchEnv: NodeJS.ProcessEnv,
|
||||
key: 'CODEX_HOME' | 'ORCA_CODEX_HOME' | 'HOME' | 'SHELL'
|
||||
): string | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(launchEnv, key) ? launchEnv[key] : process.env[key]
|
||||
}
|
||||
|
||||
function getEffectiveCodexHomeEnv(launchEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return {
|
||||
CODEX_HOME: readLaunchEnvValue(launchEnv, 'CODEX_HOME'),
|
||||
ORCA_CODEX_HOME: readLaunchEnvValue(launchEnv, 'ORCA_CODEX_HOME')
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexRuntimeHomeService {
|
||||
// Why: tracks whether the runtime auth.json currently mirrors a managed
|
||||
// account. When null, runtime auth follows the user's system-default
|
||||
@@ -140,7 +155,10 @@ export class CodexRuntimeHomeService {
|
||||
* Historical session bridging is requested in the background so launch setup
|
||||
* returns as soon as the active runtime home is ready.
|
||||
*/
|
||||
prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null {
|
||||
prepareForCodexLaunch(
|
||||
target?: CodexAccountSelectionTarget,
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
): string | null {
|
||||
if (target?.runtime === 'wsl') {
|
||||
const wslTarget = this.resolveWslDefaultTarget(target)
|
||||
const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget)
|
||||
@@ -149,7 +167,7 @@ export class CodexRuntimeHomeService {
|
||||
this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath)
|
||||
return runtimeHomePath
|
||||
}
|
||||
if (this.isHostSystemDefaultRealHome()) {
|
||||
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;
|
||||
// sessions, auth, and config all live in the native home. No system->
|
||||
@@ -197,8 +215,14 @@ export class CodexRuntimeHomeService {
|
||||
})
|
||||
}
|
||||
|
||||
getHostRuntimeHomePath(): string {
|
||||
return this.getRuntimeHomePath()
|
||||
getHostCodexHomePathsForSessionDiscovery(): string[] {
|
||||
const homes = [this.getRuntimeHomePath()]
|
||||
if (this.isHostSystemDefaultRealHomeSelected()) {
|
||||
// Why: nested Orca processes can retain an ambient managed CODEX_HOME;
|
||||
// explicitly include the real lane so its sessions remain discoverable.
|
||||
homes.push(getSystemCodexHomePath())
|
||||
}
|
||||
return homes.filter((home, index) => homes.indexOf(home) === index)
|
||||
}
|
||||
|
||||
// Why: the real-home hook installer flips this gate off when the trust-grant
|
||||
@@ -213,17 +237,32 @@ export class CodexRuntimeHomeService {
|
||||
// Why: real-home routing applies only to the host system-default selection
|
||||
// with the staged flag ON. Managed accounts keep hot-swap isolation; custom
|
||||
// CODEX_HOMEs stay managed until phase 1 can track cleanup across old homes.
|
||||
isHostSystemDefaultRealHomeSelected(): boolean {
|
||||
isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean {
|
||||
const settings = this.store.getSettings()
|
||||
return (
|
||||
isCodexSystemDefaultRealHomeEnabled(settings) &&
|
||||
normalizeCodexRuntimeSelection(settings).host === null &&
|
||||
!hasCustomCodexHomeOverride()
|
||||
if (
|
||||
!isCodexSystemDefaultRealHomeEnabled(settings) ||
|
||||
normalizeCodexRuntimeSelection(settings).host !== null
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Why: PTY callers can overlay environment values that the Electron main
|
||||
// process never inherited. Those custom homes must keep the managed lane.
|
||||
const effectiveEnv = launchEnv ? getEffectiveCodexHomeEnv(launchEnv) : process.env
|
||||
if (hasCustomCodexHomeOverride(effectiveEnv)) {
|
||||
return false
|
||||
}
|
||||
// Why: Finder/Dock launches do not inherit shell exports, but the login
|
||||
// shell can re-export a custom home after spawn and bypass the trusted lane.
|
||||
const shellCodexHome = readShellStartupEnvVar(
|
||||
'CODEX_HOME',
|
||||
launchEnv ? readLaunchEnvValue(launchEnv, 'HOME') : process.env.HOME,
|
||||
launchEnv ? readLaunchEnvValue(launchEnv, 'SHELL') : process.env.SHELL
|
||||
)
|
||||
return !hasCustomCodexHomeOverride({ CODEX_HOME: shellCodexHome })
|
||||
}
|
||||
|
||||
isHostSystemDefaultRealHome(): boolean {
|
||||
return this.isHostSystemDefaultRealHomeSelected() && this.realHomeLaneGate()
|
||||
isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean {
|
||||
return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate()
|
||||
}
|
||||
|
||||
syncActiveWslSelectionsBeforeRestart(): void {
|
||||
|
||||
@@ -194,6 +194,49 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
expect(lane).toBe('installed')
|
||||
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(firstRaw)
|
||||
})
|
||||
|
||||
it('keeps later user hook trust positions stable when reconciling an existing install', () => {
|
||||
grantSucceeds()
|
||||
const userBefore = { hooks: [{ type: 'command', command: 'before.sh' }] }
|
||||
writeFileSync(
|
||||
getRealHooksJsonPath(),
|
||||
`${JSON.stringify({ hooks: { Stop: [userBefore] } }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const installed = readRealHooksJson()
|
||||
const userAfter = { hooks: [{ type: 'command', command: 'after.sh' }] }
|
||||
installed.hooks!.Stop!.push(userAfter)
|
||||
writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8')
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
|
||||
'installed'
|
||||
)
|
||||
|
||||
const reconciled = readRealHooksJson().hooks?.Stop
|
||||
expect(reconciled?.[0]).toEqual(userBefore)
|
||||
expect(reconciled?.[2]).toEqual(userAfter)
|
||||
const plan = grantMock.mock.calls.at(-1)![0] as CodexManagedTrustGrantPlan
|
||||
expect(plan.managedEntries.find((entry) => entry.eventLabel === 'stop')?.groupIndex).toBe(1)
|
||||
})
|
||||
|
||||
it("keeps later user handler trust positions stable inside Orca's hook group", () => {
|
||||
grantSucceeds()
|
||||
ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const installed = readRealHooksJson()
|
||||
const userAfter = { type: 'command', command: 'after.sh' }
|
||||
installed.hooks!.Stop![0]!.hooks!.push(userAfter)
|
||||
writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8')
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
|
||||
'installed'
|
||||
)
|
||||
|
||||
expect(readRealHooksJson().hooks?.Stop?.[0]?.hooks?.[1]).toEqual(userAfter)
|
||||
const plan = grantMock.mock.calls.at(-1)![0] as CodexManagedTrustGrantPlan
|
||||
const stopEntry = plan.managedEntries.find((entry) => entry.eventLabel === 'stop')
|
||||
expect(stopEntry).toMatchObject({ groupIndex: 0, handlerIndex: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureRealHomeCodexHookState (opt-out sweep)', () => {
|
||||
|
||||
@@ -120,16 +120,13 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
|
||||
const managedEntries: CodexTrustEntry[] = []
|
||||
for (const eventName of material.events) {
|
||||
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
// Why: append LAST. Codex trust keys are positional
|
||||
// (source:event:group:handler); prepending would shift every user entry
|
||||
// and invalidate the user's own hook trust records.
|
||||
nextHooks[eventName] = [...cleaned, { hooks: [buildManagedCommandHook(material.command)] }]
|
||||
const reconciled = reconcileManagedHookDefinition(current, isManagedCommand, material.command)
|
||||
nextHooks[eventName] = reconciled.definitions
|
||||
managedEntries.push({
|
||||
sourcePath: hooksJsonPath,
|
||||
eventLabel: material.eventLabel[eventName],
|
||||
groupIndex: cleaned.length,
|
||||
handlerIndex: 0,
|
||||
groupIndex: reconciled.groupIndex,
|
||||
handlerIndex: reconciled.handlerIndex,
|
||||
command: material.command,
|
||||
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
|
||||
})
|
||||
@@ -177,6 +174,46 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
function reconcileManagedHookDefinition(
|
||||
current: HookDefinition[],
|
||||
isManagedCommand: (command: string | undefined) => boolean,
|
||||
command: string
|
||||
): { definitions: HookDefinition[]; groupIndex: number; handlerIndex: number } {
|
||||
const directCommandKeys = ['command', 'bash', 'powershell'] as const
|
||||
const hasManagedDirectCommand = current.some((definition) =>
|
||||
directCommandKeys.some((key) => isManagedCommand(definition[key]))
|
||||
)
|
||||
const nestedLocations = current.flatMap((definition, groupIndex) =>
|
||||
Array.isArray(definition.hooks)
|
||||
? definition.hooks.flatMap((hook, handlerIndex) =>
|
||||
isManagedCommand(hook.command) ? [{ groupIndex, handlerIndex }] : []
|
||||
)
|
||||
: []
|
||||
)
|
||||
if (!hasManagedDirectCommand && nestedLocations.length === 1) {
|
||||
const { groupIndex, handlerIndex } = nestedLocations[0]!
|
||||
const definition = current[groupIndex]!
|
||||
const hasDirectCommand = directCommandKeys.some((key) => typeof definition[key] === 'string')
|
||||
if (definition.matcher === undefined && !hasDirectCommand) {
|
||||
const definitions = [...current]
|
||||
// Why: users can append groups or handlers after Orca's first install.
|
||||
// Reusing the exact slot preserves all later positional trust keys.
|
||||
const hooks = [...definition.hooks!]
|
||||
hooks[handlerIndex] = buildManagedCommandHook(command)
|
||||
definitions[groupIndex] = { ...definition, hooks }
|
||||
return { definitions, groupIndex, handlerIndex }
|
||||
}
|
||||
}
|
||||
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
// Why: first install appends LAST so no existing user trust position shifts.
|
||||
return {
|
||||
definitions: [...cleaned, { hooks: [buildManagedCommandHook(command)] }],
|
||||
groupIndex: cleaned.length,
|
||||
handlerIndex: 0
|
||||
}
|
||||
}
|
||||
|
||||
function getInstallRetryAfterMs(reason: CodexTrustGrantFallbackReason): number {
|
||||
return reason === 'unsupported' || reason === 'unsupported-cached' || reason === 'disabled'
|
||||
? Number.POSITIVE_INFINITY
|
||||
|
||||
@@ -329,6 +329,11 @@ describe('createPtySubprocess', () => {
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(21)
|
||||
})
|
||||
|
||||
it('uses a new daemon protocol for daemon-local Codex env ownership', () => {
|
||||
expect(PROTOCOL_VERSION).toBeGreaterThan(22)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
|
||||
})
|
||||
|
||||
it('resolves a missing Unix default before spawning node-pty', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
@@ -1871,6 +1876,74 @@ describe('createPtySubprocess', () => {
|
||||
expect(lastCall[2].env.CODEX_HOME).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deletes daemon-owned Codex overlay pairs when the private marker is requested', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const previousCodexHome = process.env.CODEX_HOME
|
||||
const previousOrcaCodexHome = process.env.ORCA_CODEX_HOME
|
||||
process.env.CODEX_HOME = '/daemon/managed/codex-home'
|
||||
process.env.ORCA_CODEX_HOME = '/daemon/managed/codex-home'
|
||||
|
||||
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)
|
||||
const previousCodexHome = process.env.CODEX_HOME
|
||||
const previousOrcaCodexHome = process.env.ORCA_CODEX_HOME
|
||||
process.env.CODEX_HOME = '/daemon/user/codex-home'
|
||||
process.env.ORCA_CODEX_HOME = '/daemon/stale/managed-home'
|
||||
|
||||
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).toBe('/daemon/user/codex-home')
|
||||
expect(env.ORCA_CODEX_HOME).toBeUndefined()
|
||||
})
|
||||
|
||||
it('honors explicit terminal env overrides after deleting requested defaults', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
@@ -122,6 +122,25 @@ export type PtySubprocessOptions = {
|
||||
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
|
||||
}
|
||||
|
||||
function deleteRequestedDaemonEnvKeys(
|
||||
env: Record<string, string>,
|
||||
keys: readonly string[] | undefined
|
||||
): void {
|
||||
// Why: the persistent daemon's inherited env can differ from Electron's.
|
||||
// Compare ownership here so real-home routing neither leaks an Orca overlay
|
||||
// nor deletes a user-owned CODEX_HOME chosen by the daemon's host context.
|
||||
const deleteOrcaOwnedCodexHome =
|
||||
keys?.includes('ORCA_CODEX_HOME') === true &&
|
||||
env.ORCA_CODEX_HOME !== undefined &&
|
||||
env.CODEX_HOME === env.ORCA_CODEX_HOME
|
||||
for (const key of keys ?? []) {
|
||||
delete env[key]
|
||||
}
|
||||
if (deleteOrcaOwnedCodexHome) {
|
||||
delete env.CODEX_HOME
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable default working directory for daemon-spawned PTYs.
|
||||
*/
|
||||
@@ -584,9 +603,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
FORCE_HYPERLINK: '1'
|
||||
} as Record<string, string>
|
||||
composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent)
|
||||
for (const key of opts.envToDelete ?? []) {
|
||||
delete env[key]
|
||||
}
|
||||
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
|
||||
if (opts.env?.TERM) {
|
||||
env.TERM = opts.env.TERM
|
||||
}
|
||||
@@ -760,9 +777,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
} else {
|
||||
// Why: relay-side launch modes can ask for host defaults to stay scrubbed
|
||||
// even after environment normalization above.
|
||||
for (const key of opts.envToDelete ?? []) {
|
||||
delete env[key]
|
||||
}
|
||||
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
|
||||
if (opts.env?.TERM) {
|
||||
env.TERM = opts.env.TERM
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ import type { TuiAgent } from '../../shared/types'
|
||||
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
|
||||
// Why: bump when adding daemon wire behavior so same-version old daemons do
|
||||
// not silently accept the handshake and then reject new RPCs.
|
||||
export const PROTOCOL_VERSION = 22
|
||||
export const PROTOCOL_VERSION = 23
|
||||
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
|
||||
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22
|
||||
] as const
|
||||
|
||||
// ─── Session State Machine ──────────────────────────────────────────
|
||||
|
||||
+14
-10
@@ -737,8 +737,14 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
|
||||
return firstWindowStartupServicesReady
|
||||
}
|
||||
|
||||
function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null {
|
||||
if (target?.runtime !== 'wsl' && codexRuntimeHome!.isHostSystemDefaultRealHomeSelected()) {
|
||||
function prepareCodexRuntimeHomeForLaunch(
|
||||
target?: CodexAccountSelectionTarget,
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
): string | null {
|
||||
if (
|
||||
target?.runtime !== 'wsl' &&
|
||||
codexRuntimeHome!.isHostSystemDefaultRealHomeSelected(launchEnv)
|
||||
) {
|
||||
// Why (flag ON, system default): the hook entry must exist — appended last
|
||||
// and trusted by codex's own app-server grant — in the real ~/.codex before
|
||||
// the pane spawns. An incapable grant flips the lane gate so the launch
|
||||
@@ -748,8 +754,8 @@ function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget):
|
||||
userDataPath: app.getPath('userData')
|
||||
})
|
||||
}
|
||||
const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target)
|
||||
if (runtimeHomePath === null && codexRuntimeHome!.isHostSystemDefaultRealHome()) {
|
||||
const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv)
|
||||
if (runtimeHomePath === null && target?.runtime !== 'wsl') {
|
||||
// Why: Codex runs on the user's real ~/.codex; the managed-home hook
|
||||
// install below would target a home Codex never reads on this lane.
|
||||
return null
|
||||
@@ -1000,7 +1006,7 @@ function openMainWindow(): BrowserWindow {
|
||||
keybindings,
|
||||
{
|
||||
getAdditionalAiVaultCodexHomePaths: () =>
|
||||
codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [],
|
||||
codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [],
|
||||
onBeforeRelaunch: async () => {
|
||||
isQuitting = true
|
||||
desktopRelayService?.fenceAndCloseNow()
|
||||
@@ -1882,7 +1888,7 @@ app.whenReady().then(async () => {
|
||||
// aiVault.listSessions RPC includes managed-Codex sessions on remote/SSH
|
||||
// hosts; the window-only registerCoreHandlers path never runs under serve.
|
||||
getAdditionalAiVaultCodexHomePaths: () =>
|
||||
codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [],
|
||||
codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [],
|
||||
buildAgentHookPtyEnv: () =>
|
||||
isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}
|
||||
})
|
||||
@@ -2020,15 +2026,13 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
}
|
||||
if (codexRuntimeHome.isHostSystemDefaultRealHomeSelected()) {
|
||||
// Why: host-connect seam — install (or sweep, when opted out) the trusted
|
||||
// real-home status hook before the first pane can spawn, so the very first
|
||||
// launch already knows whether this host's grant lane is usable.
|
||||
// Why: establish the lane before background rate-limit polling starts, so
|
||||
// an incapable grant host never polls a home its PTYs will not use.
|
||||
ensureRealHomeCodexHookState({
|
||||
hooksEnabled: isAgentStatusHooksEnabled(store.getSettings()),
|
||||
userDataPath: app.getPath('userData')
|
||||
})
|
||||
}
|
||||
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, {
|
||||
name: details.name,
|
||||
|
||||
@@ -749,7 +749,10 @@ describe('registerPtyHandlers', () => {
|
||||
async function spawnAndGetEnv(
|
||||
argsEnv?: Record<string, string>,
|
||||
processEnvOverrides?: Record<string, string | undefined>,
|
||||
getSelectedCodexHomePath?: () => string | null,
|
||||
getSelectedCodexHomePath?: (
|
||||
target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null },
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
) => string | null,
|
||||
getSettings?: () => {
|
||||
enableGitHubAttribution?: boolean
|
||||
agentStatusHooksEnabled?: boolean
|
||||
@@ -1398,6 +1401,28 @@ describe('registerPtyHandlers', () => {
|
||||
expect(env.ORCA_CODEX_HOME).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lets the resolver keep a per-spawn custom CODEX_HOME on the managed lane', async () => {
|
||||
const customHome = '/home/me/.config/codex'
|
||||
let resolvedCodexHome: string | undefined
|
||||
const resolveHome = vi.fn((_target: unknown, launchEnv?: NodeJS.ProcessEnv) => {
|
||||
resolvedCodexHome = launchEnv?.CODEX_HOME
|
||||
return launchEnv?.CODEX_HOME === customHome ? TEST_CODEX_HOME : null
|
||||
})
|
||||
|
||||
const env = await spawnAndGetEnv(
|
||||
{ CODEX_HOME: customHome },
|
||||
{ CODEX_HOME: undefined, ORCA_CODEX_HOME: undefined },
|
||||
resolveHome,
|
||||
() => ({ codexSystemDefaultRealHomeEnabled: true }) as never
|
||||
)
|
||||
|
||||
expect(resolveHome).toHaveBeenCalledTimes(1)
|
||||
expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' })
|
||||
expect(resolvedCodexHome).toBe(customHome)
|
||||
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
|
||||
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
|
||||
})
|
||||
|
||||
it('injects explicit proxy settings into local PTY env', async () => {
|
||||
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
|
||||
httpProxyUrl: 'http://proxy.example:8080',
|
||||
@@ -1759,9 +1784,9 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
expect(spawnOptions.env.CODEX_HOME).toBeUndefined()
|
||||
expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined()
|
||||
expect(spawnOptions.envToDelete).toEqual(
|
||||
expect.arrayContaining(['CODEX_HOME', 'ORCA_CODEX_HOME'])
|
||||
)
|
||||
expect(spawnOptions.envToDelete).toEqual(expect.arrayContaining(['ORCA_CODEX_HOME']))
|
||||
// The daemon compares its own merged values before deleting CODEX_HOME.
|
||||
expect(spawnOptions.envToDelete).not.toContain('CODEX_HOME')
|
||||
})
|
||||
|
||||
it('preserves a daemon-inherited user CODEX_HOME for real-home routing', async () => {
|
||||
|
||||
+19
-19
@@ -647,16 +647,14 @@ const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const
|
||||
// the marker so a shell-ready wrapper cannot restore the managed home. A
|
||||
// user-set CODEX_HOME with no Orca marker is preserved untouched (see #8606).
|
||||
function stripInheritedOrcaCodexHomeOverride(baseEnv: Record<string, string>): void {
|
||||
for (const key of getInheritedOrcaCodexHomeEnvKeysToDelete(baseEnv)) {
|
||||
for (const key of getLocalOrcaCodexHomeEnvKeysToDelete(baseEnv)) {
|
||||
delete baseEnv[key]
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the daemon spawns the PTY from its own inherited environment and honors
|
||||
// only spawnOptions.envToDelete, so mutating the sparse env object is not enough
|
||||
// to strip an Orca-owned CODEX_HOME the daemon already carries. Return the exact
|
||||
// keys to delete, preserving a user-owned CODEX_HOME.
|
||||
function getInheritedOrcaCodexHomeEnvKeysToDelete(env: Record<string, string>): string[] {
|
||||
// Why: in-process spawns share main's inherited environment, so equality with
|
||||
// the private marker is authoritative here. Persistent daemons compare locally.
|
||||
function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record<string, string>): string[] {
|
||||
const inheritedOrcaOverride = env.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME
|
||||
const inheritedCodexHome = env.CODEX_HOME ?? process.env.CODEX_HOME
|
||||
const keysToDelete = ['ORCA_CODEX_HOME']
|
||||
@@ -666,7 +664,10 @@ function getInheritedOrcaCodexHomeEnvKeysToDelete(env: Record<string, string>):
|
||||
return keysToDelete
|
||||
}
|
||||
|
||||
type GetSelectedCodexHomePath = (target?: CodexAccountSelectionTarget) => string | null
|
||||
type GetSelectedCodexHomePath = (
|
||||
target?: CodexAccountSelectionTarget,
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
) => string | null
|
||||
type PrepareClaudeAuth = (
|
||||
target?: ClaudeAccountSelectionTarget
|
||||
) => Promise<ClaudeRuntimeAuthPreparation>
|
||||
@@ -1604,7 +1605,7 @@ export function registerPtyHandlers(
|
||||
: { runtime: 'host' }
|
||||
const selectedCodexHomePath = getCompatibleSelectedCodexHomePath(
|
||||
codexSelectionTarget,
|
||||
getSelectedCodexHomePath?.(codexSelectionTarget) ?? null
|
||||
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv) ?? null
|
||||
)
|
||||
const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath
|
||||
const env = buildPtyHostEnv(id, baseEnv, {
|
||||
@@ -3086,7 +3087,7 @@ export function registerPtyHandlers(
|
||||
const selectedCodexHomePath = isDaemonHostSpawn
|
||||
? getCompatibleSelectedCodexHomePath(
|
||||
codexSelectionTarget,
|
||||
getSelectedCodexHomePath?.(codexSelectionTarget) ?? null
|
||||
getSelectedCodexHomePath?.(codexSelectionTarget, env) ?? null
|
||||
)
|
||||
: null
|
||||
const skipCodexHomeEnv =
|
||||
@@ -3144,12 +3145,11 @@ export function registerPtyHandlers(
|
||||
CODEX_HOME_ENV_KEYS
|
||||
)
|
||||
} else if (stripInheritedOrcaCodexHome) {
|
||||
// Why: the daemon inherits its own CODEX_HOME; strip the Orca-owned
|
||||
// override there too, preserving a user-set CODEX_HOME.
|
||||
spawnOptions.envToDelete = mergePtyEnvDeletions(
|
||||
spawnOptions.envToDelete,
|
||||
getInheritedOrcaCodexHomeEnvKeysToDelete(env ?? {})
|
||||
)
|
||||
// Why: the daemon owns a persistent inherited environment that may
|
||||
// differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair.
|
||||
spawnOptions.envToDelete = mergePtyEnvDeletions(spawnOptions.envToDelete, [
|
||||
'ORCA_CODEX_HOME'
|
||||
])
|
||||
}
|
||||
deleteRequestedEnvKeys(env, spawnOptions.envToDelete)
|
||||
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
|
||||
@@ -3969,7 +3969,7 @@ export function registerPtyHandlers(
|
||||
const selectedCodexHomePath = isDaemonHostSpawn
|
||||
? getCompatibleSelectedCodexHomePath(
|
||||
codexSelectionTarget,
|
||||
getSelectedCodexHomePath?.(codexSelectionTarget) ?? null
|
||||
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv) ?? null
|
||||
)
|
||||
: null
|
||||
const skipCodexHomeEnv =
|
||||
@@ -4055,9 +4055,9 @@ export function registerPtyHandlers(
|
||||
),
|
||||
skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : []
|
||||
),
|
||||
// Why: real-home routing strips the Orca-owned override the daemon
|
||||
// inherits, while preserving a user-set CODEX_HOME.
|
||||
stripInheritedOrcaCodexHome ? getInheritedOrcaCodexHomeEnvKeysToDelete(spawnEnv ?? {}) : []
|
||||
// Why: the persistent daemon compares its own merged CODEX_HOME pair;
|
||||
// main cannot safely decide ownership for a process it may not parent.
|
||||
stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : []
|
||||
)
|
||||
deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete)
|
||||
promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath)
|
||||
|
||||
@@ -68,7 +68,10 @@ export function attachMainWindowServices(
|
||||
mainWindow: BrowserWindow,
|
||||
store: Store,
|
||||
runtime: OrcaRuntimeService,
|
||||
getSelectedCodexHomePath?: (target?: CodexAccountSelectionTarget) => string | null,
|
||||
getSelectedCodexHomePath?: (
|
||||
target?: CodexAccountSelectionTarget,
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
) => string | null,
|
||||
prepareClaudeAuth?: (
|
||||
target?: ClaudeAccountSelectionTarget
|
||||
) => Promise<ClaudeRuntimeAuthPreparation>,
|
||||
|
||||
Reference in New Issue
Block a user