diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 1f5c5bf0ab4..7300cb74909 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -9,8 +9,11 @@ "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", + "../src/main/codex/codex-config-mirror.ts", + "../src/main/codex/codex-home-paths.ts", "../src/main/codex/config-toml-trust.ts", "../src/main/codex/hook-service.ts", + "../src/main/codex-accounts/fs-utils.ts", "../src/main/copilot/hook-service.ts", "../src/main/cursor/hook-service.ts", "../src/main/droid/hook-service.ts", diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 59d354933e2..0b69077d3b1 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -18,6 +18,8 @@ export type HookCommandConfig = { type: 'command' command: string timeout?: number + async?: boolean + statusMessage?: string [key: string]: unknown } diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 9bbb5401630..3fabb160d9a 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -17,15 +17,7 @@ type ManagedHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstal export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [ ['claude', () => claudeHookService.install()], - [ - 'codex', - () => { - // Why: the Orca-specific Codex profile keeps normal external `codex` - // runs from loading Orca hooks; remove legacy global entries after it is ready. - codexHookService.installProfile() - codexHookService.remove() - } - ], + ['codex', () => codexHookService.install()], ['gemini', () => geminiHookService.install()], ['antigravity', () => antigravityHookService.install()], ['cursor', () => cursorHookService.install()], @@ -37,14 +29,7 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ ['claude', () => claudeHookService.remove()], - [ - 'codex', - () => { - const globalStatus = codexHookService.remove() - const profileStatus = codexHookService.removeProfile() - return profileStatus.state === 'error' ? profileStatus : globalStatus - } - ], + ['codex', () => codexHookService.remove()], ['gemini', () => geminiHookService.remove()], ['antigravity', () => antigravityHookService.remove()], ['cursor', () => cursorHookService.remove()], @@ -56,7 +41,7 @@ const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [ ['claude', () => claudeHookService.getStatus()], - ['codex', () => codexHookService.getProfileStatus()], + ['codex', () => codexHookService.getStatus()], ['gemini', () => geminiHookService.getStatus()], ['antigravity', () => antigravityHookService.getStatus()], ['cursor', () => cursorHookService.getStatus()], diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 34e502f8476..48ad1f8fe23 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -193,54 +193,6 @@ describe('remote hook service installers', () => { expect(toml).toContain('trusted_hash = "sha256:') }) - it('installs remote Codex profile hooks and sweeps legacy global entries', async () => { - const { sftp, fs } = createFakeSftp({ - '/home/dev/.codex/hooks.json': JSON.stringify({ - hooks: { - PreToolUse: [ - { - hooks: [ - { - type: 'command', - command: - 'if [ -x /home/dev/.orca/agent-hooks/codex-hook.sh ]; then /bin/sh /home/dev/.orca/agent-hooks/codex-hook.sh; fi' - } - ] - } - ] - } - }) - }) - - const status = await new CodexHookService().installRemoteProfile(sftp, '/home/dev/') - - expect(status.state).toBe('installed') - expect(status.configPath).toBe('/home/dev/.codex/orca-agent-status.config.toml') - const profile = fs.files.get('/home/dev/.codex/orca-agent-status.config.toml')! - expect(profile).toContain('[[hooks.PermissionRequest]]') - expect(profile).toContain( - '/home/dev/.codex/orca-agent-status.config.toml:permission_request:0:0' - ) - expect(profile).toContain('/home/dev/.orca/agent-hooks/codex-hook.sh') - expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh') - const globalHooks = JSON.parse(fs.files.get('/home/dev/.codex/hooks.json')!) as { - hooks?: Record - } - expect(globalHooks.hooks?.PreToolUse).toBeUndefined() - }) - - it('does not create remote legacy Codex hooks.json when profile install has nothing to sweep', async () => { - const { sftp, fs } = createFakeSftp() - - const status = await new CodexHookService().installRemoteProfile(sftp, '/home/dev/') - - expect(status.state).toBe('installed') - expect(fs.files.get('/home/dev/.codex/orca-agent-status.config.toml')).toContain( - '[[hooks.PermissionRequest]]' - ) - expect(fs.files.has('/home/dev/.codex/hooks.json')).toBe(false) - }) - it('reports Codex trust-write failures without rolling back installed hooks', async () => { const { sftp, fs } = createFakeSftp() fs.failRenameTo.add('/home/dev/.codex/config.toml') diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 3baa96f9d27..a8ef3d9109e 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -15,7 +15,7 @@ type RemoteManagedHookInstaller = readonly [ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['claude', (sftp, remoteHome) => claudeHookService.installRemote(sftp, remoteHome)], - ['codex', (sftp, remoteHome) => codexHookService.installRemoteProfile(sftp, remoteHome)], + ['codex', (sftp, remoteHome) => codexHookService.installRemote(sftp, remoteHome)], ['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)], ['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)], ['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)], diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index f950abc6e9b..c24e9c0c544 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -1765,6 +1765,110 @@ describe('AgentHookServer listener replay', () => { } }) + it('tracks Codex agent statuses from form-encoded managed hook posts', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const listener = vi.fn() + server.setListener(listener) + const postCodexHook = async (payload: Record): Promise => { + const params = new URLSearchParams({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'production', + version: env.ORCA_AGENT_HOOK_VERSION ?? '', + payload: JSON.stringify(payload) + }) + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: params + }) + expect(response.status).toBe(204) + } + + await postCodexHook({ + hook_event_name: 'UserPromptSubmit', + prompt: 'ship codex hook status' + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + state: 'working', + agentType: 'codex', + prompt: 'ship codex hook status', + toolName: undefined, + toolInput: undefined + }) + ]) + + await postCodexHook({ + hook_event_name: 'PreToolUse', + tool_name: 'exec_command', + tool_input: { cmd: 'pnpm test', workdir: '/repo' } + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + agentType: 'codex', + prompt: 'ship codex hook status', + toolName: 'exec_command', + toolInput: 'pnpm test' + }) + ]) + + await postCodexHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'exec_command', + tool_input: { cmd: 'git push', workdir: '/repo' } + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'waiting', + agentType: 'codex', + prompt: 'ship codex hook status', + toolName: 'exec_command', + toolInput: 'git push' + }) + ]) + + await postCodexHook({ + hook_event_name: 'Stop', + last_assistant_message: 'done' + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + agentType: 'codex', + prompt: 'ship codex hook status', + lastAssistantMessage: 'done' + }) + ]) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: expect.objectContaining({ + state: 'done', + agentType: 'codex', + prompt: 'ship codex hook status', + lastAssistantMessage: 'done' + }) + }) + ) + } finally { + server.stop() + } + }) + it('accepts Hermes plugin hook posts on /hook/hermes', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index 4b4575fd6c2..805be4227e2 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -13,9 +13,22 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const testState = { - fakeHomeDir: '' + fakeHomeDir: '', + userDataDir: '', + previousUserDataPath: undefined as string | undefined } +vi.mock('electron', () => ({ + app: { + getPath: (name: string) => { + if (name === 'userData') { + return testState.userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + } + } +})) + vi.mock('node:os', async () => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const actual = await vi.importActual('node:os') @@ -30,11 +43,22 @@ const { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTr beforeEach(() => { testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-')) + testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-user-data-')) + testState.previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = testState.userDataDir }) afterEach(() => { rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + rmSync(testState.userDataDir, { recursive: true, force: true }) + if (testState.previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = testState.previousUserDataPath + } testState.fakeHomeDir = '' + testState.userDataDir = '' + testState.previousUserDataPath = undefined }) describe('markCursorWorkspaceTrusted', () => { @@ -119,10 +143,20 @@ describe('markCodexProjectTrusted', () => { const realpath = realpathSync(workspace) markCodexProjectTrusted(workspace) const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml') + const runtimeConfigPath = join( + testState.userDataDir, + 'codex-runtime-home', + 'home', + 'config.toml' + ) expect(existsSync(configPath)).toBe(true) + expect(existsSync(runtimeConfigPath)).toBe(true) const written = readFileSync(configPath, 'utf-8') + const runtimeWritten = readFileSync(runtimeConfigPath, 'utf-8') expect(written).toContain(`[projects."${escapeTomlBasicString(realpath)}"]`) expect(written).toContain('trust_level = "trusted"') + expect(runtimeWritten).toContain(`[projects."${escapeTomlBasicString(realpath)}"]`) + expect(runtimeWritten).toContain('trust_level = "trusted"') } finally { rmSync(workspace, { recursive: true, force: true }) } @@ -133,7 +167,9 @@ describe('markCodexProjectTrusted', () => { const realpath = realpathSync(workspace) try { const codexDir = join(testState.fakeHomeDir, '.codex') + const runtimeCodexDir = join(testState.userDataDir, 'codex-runtime-home', 'home') mkdirSync(codexDir, { recursive: true }) + mkdirSync(runtimeCodexDir, { recursive: true }) writeFileSync( join(codexDir, 'config.toml'), [ @@ -146,14 +182,31 @@ describe('markCodexProjectTrusted', () => { ].join('\n'), 'utf-8' ) + writeFileSync( + join(runtimeCodexDir, 'config.toml'), + [ + 'sandbox_mode = "workspace-write"', + '', + `[projects."${escapeTomlBasicString(realpath)}"]`, + 'notes = "keep-runtime"', + 'trust_level = "untrusted"', + '' + ].join('\n'), + 'utf-8' + ) markCodexProjectTrusted(workspace) const written = readFileSync(join(codexDir, 'config.toml'), 'utf-8') + const runtimeWritten = readFileSync(join(runtimeCodexDir, 'config.toml'), 'utf-8') expect(written).toContain('model = "gpt-5.5"') expect(written).toContain('notes = "keep"') expect(written).toContain('trust_level = "trusted"') expect(written).not.toContain('trust_level = "untrusted"') + expect(runtimeWritten).toContain('sandbox_mode = "workspace-write"') + expect(runtimeWritten).toContain('notes = "keep-runtime"') + expect(runtimeWritten).toContain('trust_level = "trusted"') + expect(runtimeWritten).not.toContain('trust_level = "untrusted"') } finally { rmSync(workspace, { recursive: true, force: true }) } diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index f71ed9e4408..56220d41f4a 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { writeFileAtomically } from './codex-accounts/fs-utils' +import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' /** @@ -109,6 +110,9 @@ export function markCodexProjectTrusted(workspacePath: string): void { const absPath = canonicalize(workspacePath) const configPath = join(homedir(), '.codex', 'config.toml') upsertProjectTrustLevel(configPath, absPath, 'trusted') + // Why: Orca-launched Codex runs with an Orca-owned CODEX_HOME, so the trust + // preset must also update the runtime config Codex will actually read. + upsertProjectTrustLevel(join(getOrcaManagedCodexHomePath(), 'config.toml'), absPath, 'trusted') } function canonicalize(p: string): string { diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 185497cf581..0224b74b4e8 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { chmodSync, existsSync, + lstatSync, mkdtempSync, mkdirSync, + readlinkSync, readFileSync, rmSync, statSync, @@ -14,7 +16,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { GlobalSettings } from '../../shared/types' -const testState = { userDataDir: '', fakeHomeDir: '' } +const testState = { + userDataDir: '', + fakeHomeDir: '', + previousUserDataPath: undefined as string | undefined +} vi.mock('electron', () => ({ app: { @@ -122,6 +128,36 @@ function createSettings(overrides: Partial = {}): GlobalSettings } } +function getSystemCodexHomePath(): string { + return join(testState.fakeHomeDir, '.codex') +} + +function getSystemCodexAuthPath(): string { + return join(getSystemCodexHomePath(), 'auth.json') +} + +function getRuntimeCodexHomePath(): string { + return join(testState.userDataDir, 'codex-runtime-home', 'home') +} + +function getRuntimeCodexAuthPath(): string { + return join(getRuntimeCodexHomePath(), 'auth.json') +} + +function normalizeLinkTarget(linkTarget: string): string { + return process.platform === 'win32' + ? linkTarget.replace(/^\\\\\?\\/, '').toLowerCase() + : linkTarget +} + +function expectResourceLinkedOrCopied(targetPath: string, sourcePath: string): void { + expect(existsSync(targetPath)).toBe(true) + if (!lstatSync(targetPath).isSymbolicLink()) { + return + } + expect(normalizeLinkTarget(readlinkSync(targetPath))).toBe(normalizeLinkTarget(sourcePath)) +} + function createStore(settings: GlobalSettings) { return { getSettings: vi.fn(() => settings), @@ -186,17 +222,24 @@ describe('CodexRuntimeHomeService', () => { vi.clearAllMocks() testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-runtime-home-')) testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) - mkdirSync(join(testState.fakeHomeDir, '.codex'), { recursive: true }) + testState.previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = testState.userDataDir + mkdirSync(getSystemCodexHomePath(), { recursive: true }) + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) }) afterEach(() => { rmSync(testState.userDataDir, { recursive: true, force: true }) rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + if (testState.previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = testState.previousUserDataPath + } }) it('captures the existing ~/.codex auth as the system-default snapshot', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') @@ -213,9 +256,9 @@ describe('CodexRuntimeHomeService', () => { } }) - it('materializes the active managed account auth into ~/.codex on startup', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + it('materializes the active managed account auth into the runtime home on startup', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -250,8 +293,8 @@ describe('CodexRuntimeHomeService', () => { }) it('restores the system-default snapshot when no managed account is selected', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -288,7 +331,7 @@ describe('CodexRuntimeHomeService', () => { }) it('removes runtime auth when restoring a no-login system default', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -324,7 +367,7 @@ describe('CodexRuntimeHomeService', () => { }) it('removes runtime auth when deselecting with a missing system-default snapshot', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') writeFileSync(runtimeAuthPath, managedAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) @@ -355,9 +398,9 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(runtimeAuthPath)).toBe(false) }) - it('removes runtime auth when deselecting with a corrupt system-default snapshot', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + it('repairs a corrupt system-default snapshot from the live ~/.codex auth on deselect', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ @@ -377,7 +420,6 @@ describe('CodexRuntimeHomeService', () => { activeCodexManagedAccountId: null }) const store = createStore(settings) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -393,15 +435,15 @@ describe('CodexRuntimeHomeService', () => { settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(existsSync(snapshotPath)).toBe(false) - expect(warnSpy).toHaveBeenCalledWith( - '[codex-runtime-home] Ignoring invalid system-default auth snapshot' - ) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + expect(existsSync(snapshotPath)).toBe(true) + expect(JSON.parse(readFileSync(snapshotPath, 'utf-8'))).toEqual({ + authJson: '{"account":"system"}\n' + }) }) it('clears an invalid active account selection and removes untrusted runtime auth', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const missingManagedHomePath = join( testState.userDataDir, @@ -436,8 +478,46 @@ describe('CodexRuntimeHomeService', () => { expect(warnSpy).toHaveBeenCalled() }) + it('clears an invalid active account selection and restores live system default auth', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = '{"account":"system"}\n' + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + rmSync(runtimeAuthPath, { force: true }) + const missingManagedHomePath = join( + testState.userDataDir, + 'codex-accounts', + 'account-1', + 'home' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath: missingManagedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) + expect(warnSpy).toHaveBeenCalled() + }) + it('clears an unknown active account id and removes untrusted runtime auth', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"stale-managed"}\n', 'utf-8') const settings = createSettings({ activeCodexManagedAccountId: 'missing-account' @@ -451,18 +531,91 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(runtimeAuthPath)).toBe(false) }) - it('returns ~/.codex for Codex launch and rate-limit preparation', async () => { + it('returns the Orca-managed runtime home for Codex launch and rate-limit preparation', async () => { const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch()).toBe(join(testState.fakeHomeDir, '.codex')) - expect(service.prepareForRateLimitFetch()).toBe(join(testState.fakeHomeDir, '.codex')) - expect(existsSync(join(testState.fakeHomeDir, '.codex'))).toBe(true) + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) + expect(existsSync(getRuntimeCodexHomePath())).toBe(true) + }) + + it('mirrors later system Codex config changes before launch', async () => { + const systemCodexHome = getSystemCodexHomePath() + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "first"\n', 'utf-8') + const store = createStore(createSettings()) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + service.prepareForCodexLaunch() + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "second"\n', 'utf-8') + service.prepareForCodexLaunch() + + expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe( + 'model = "second"\n' + ) + }) + + it('links system Codex user resources into the managed runtime home before launch', async () => { + const systemCodexHome = getSystemCodexHomePath() + mkdirSync(join(systemCodexHome, 'skills', 'review'), { recursive: true }) + writeFileSync(join(systemCodexHome, 'skills', 'review', 'SKILL.md'), 'review skill\n', 'utf-8') + mkdirSync(join(systemCodexHome, 'plugins'), { recursive: true }) + writeFileSync(join(systemCodexHome, 'plugins', 'plugin.json'), '{"name":"plugin"}\n', 'utf-8') + writeFileSync(join(systemCodexHome, 'profile-v2'), 'profile\n', 'utf-8') + const store = createStore(createSettings()) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + service.prepareForCodexLaunch() + + const runtimeSkillsPath = join(getRuntimeCodexHomePath(), 'skills') + const runtimePluginsPath = join(getRuntimeCodexHomePath(), 'plugins') + const runtimeProfilePath = join(getRuntimeCodexHomePath(), 'profile-v2') + expectResourceLinkedOrCopied(runtimeSkillsPath, join(systemCodexHome, 'skills')) + expectResourceLinkedOrCopied(runtimePluginsPath, join(systemCodexHome, 'plugins')) + expectResourceLinkedOrCopied(runtimeProfilePath, join(systemCodexHome, 'profile-v2')) + expect(readFileSync(join(runtimeSkillsPath, 'review', 'SKILL.md'), 'utf-8')).toBe( + 'review skill\n' + ) + expect(readFileSync(runtimeProfilePath, 'utf-8')).toBe('profile\n') + }) + + it('does not replace runtime-owned Codex files while linking user resources', async () => { + const systemCodexHome = getSystemCodexHomePath() + mkdirSync(join(systemCodexHome, 'sessions'), { recursive: true }) + mkdirSync(join(systemCodexHome, 'skills'), { recursive: true }) + writeFileSync(join(systemCodexHome, 'auth.json'), '{"account":"system"}\n', 'utf-8') + writeFileSync(join(systemCodexHome, 'hooks.json'), '{"hooks":{}}\n', 'utf-8') + writeFileSync(join(systemCodexHome, 'skills', 'system.md'), 'system\n', 'utf-8') + writeFileSync(join(getRuntimeCodexHomePath(), 'hooks.json'), '{"hooks":{"Stop":[]}}\n', 'utf-8') + writeFileSync(join(getRuntimeCodexHomePath(), 'history.jsonl'), '{"id":"runtime"}\n', 'utf-8') + const store = createStore(createSettings()) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + service.prepareForCodexLaunch() + + expect(readFileSync(join(getRuntimeCodexHomePath(), 'auth.json'), 'utf-8')).toBe( + '{"account":"system"}\n' + ) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'hooks.json'), 'utf-8')).toBe( + '{"hooks":{"Stop":[]}}\n' + ) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'history.jsonl'), 'utf-8')).toBe( + '{"id":"runtime"}\n' + ) + expect(existsSync(join(getRuntimeCodexHomePath(), 'sessions'))).toBe(false) + expectResourceLinkedOrCopied( + join(getRuntimeCodexHomePath(), 'skills'), + join(systemCodexHome, 'skills') + ) }) it('does not overwrite auth.json when no managed account was ever active', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"original"}\n', 'utf-8') const store = createStore(createSettings()) @@ -475,9 +628,208 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-switch"}\n') }) - it('does not overwrite auth.json after deselection + external change', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + it('refreshes the runtime auth when the system-default auth changes later', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-1"}\n', 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-1"}\n') + + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-2"}\n', 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-2"}\n') + }) + + it('reads back system-default token refreshes from runtime auth', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-old') + const refreshedAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'system-refreshed' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + expect( + JSON.parse( + readFileSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json'), + 'utf-8' + ) + ) + ).toEqual({ authJson: refreshedAuth }) + }) + + it('reads back system-default token refreshes after restart when the snapshot proves the baseline', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-old') + const refreshedAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'system-refreshed' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + const restartedService = new CodexRuntimeHomeService(store as never) + restartedService.syncForCurrentSelection() + + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + }) + + it('keeps a local runtime logout when the system-default auth still exists', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + rmSync(runtimeAuthPath, { force: true }) + service.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + + it('keeps a local runtime logout after restart when the system-default auth still exists', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const settings = createSettings() + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + rmSync(runtimeAuthPath, { force: true }) + service.syncForCurrentSelection() + new CodexRuntimeHomeService(store as never) + + expect(existsSync(runtimeAuthPath)).toBe(false) + expect( + existsSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json') + ) + ).toBe(true) + }) + + it('mirrors a fresh external system-default login after a persisted local runtime logout', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') + const settings = createSettings() + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + rmSync(runtimeAuthPath, { force: true }) + service.syncForCurrentSelection() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-new"}\n', 'utf-8') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-new"}\n') + expect( + existsSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json') + ) + ).toBe(false) + }) + + it('mirrors a fresh external system-default login after a same-process local runtime logout', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + rmSync(runtimeAuthPath, { force: true }) + service.syncForCurrentSelection() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-new"}\n', 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-new"}\n') + expect( + existsSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json') + ) + ).toBe(false) + }) + + it('clears the mirrored runtime auth after an external system-default logout', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + rmSync(getSystemCodexAuthPath(), { force: true }) + service.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + + it('clears mirrored runtime auth after restart when the system-default auth was deleted', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const settings = createSettings() + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + rmSync(getSystemCodexAuthPath(), { force: true }) + const restartedService = new CodexRuntimeHomeService(store as never) + restartedService.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + + it('clears refreshed runtime auth after an external system-default logout', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const refreshedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'refreshed') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + rmSync(getSystemCodexAuthPath(), { force: true }) + service.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + expect( + existsSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json') + ) + ).toBe(true) + }) + + it('persists runtime auth refreshes after returning to system default', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const refreshedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'refreshed') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -509,16 +861,61 @@ describe('CodexRuntimeHomeService', () => { // Deselect managed account — should restore system default once settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) - // External tool changes auth — subsequent syncs must not overwrite - writeFileSync(runtimeAuthPath, '{"account":"external-tool"}\n', 'utf-8') + // Codex used to refresh tokens directly in ~/.codex. With an Orca-owned + // runtime home, the same refresh must be read back to the system default. + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') service.syncForCurrentSelection() - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-tool"}\n') + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedAuth) + }) + + it('does not write stale managed runtime auth back to system default', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const managedAuth = createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + const staleManagedRefresh = createCodexAuthJson( + 'managed@example.com', + 'acct-managed', + 'managed-refreshed' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + writeFileSync(runtimeAuthPath, staleManagedRefresh, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) }) it('removes untrusted runtime auth on restart when persisted active account is invalid', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const settings = createSettings({ codexManagedAccounts: [ @@ -548,7 +945,7 @@ describe('CodexRuntimeHomeService', () => { }) it('imports legacy managed-home history into the shared runtime history', async () => { - const runtimeHomePath = join(testState.fakeHomeDir, '.codex') + const runtimeHomePath = getRuntimeCodexHomePath() const runtimeHistoryPath = join(runtimeHomePath, 'history.jsonl') writeFileSync(runtimeHistoryPath, '{"id":"shared-1"}\n', 'utf-8') const managedHomePath = createManagedAuth( @@ -579,7 +976,7 @@ describe('CodexRuntimeHomeService', () => { return } - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, @@ -617,7 +1014,7 @@ describe('CodexRuntimeHomeService', () => { return } - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, @@ -688,7 +1085,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - const runtimeHistoryPath = join(testState.fakeHomeDir, '.codex', 'history.jsonl') + const runtimeHistoryPath = join(getRuntimeCodexHomePath(), 'history.jsonl') expect(readFileSync(runtimeHistoryPath, 'utf-8')).toContain('legacy-1') writeFileSync( @@ -705,7 +1102,7 @@ describe('CodexRuntimeHomeService', () => { }) it('clears system-default snapshot via clearSystemDefaultSnapshot', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const store = createStore(createSettings()) @@ -724,7 +1121,7 @@ describe('CodexRuntimeHomeService', () => { }) it('reads back CLI-refreshed tokens into managed storage on subsequent sync', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed') @@ -751,7 +1148,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Simulate CLI refreshing the token in ~/.codex/auth.json + // Simulate CLI refreshing the token in runtime CODEX_HOME/auth.json. writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') // Next sync should read back the refreshed token to managed storage @@ -762,7 +1159,7 @@ describe('CodexRuntimeHomeService', () => { }) it('rejects runtime read-back from a different Codex identity', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') const staleLivePtyAuth = createCodexAuthJson('stale@example.com', 'acct-stale', 'stale') @@ -799,7 +1196,7 @@ describe('CodexRuntimeHomeService', () => { }) it('routes runtime read-back from a different Codex identity to its matching account', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') const account1RefreshedAuth = createCodexAuthJson( @@ -846,7 +1243,7 @@ describe('CodexRuntimeHomeService', () => { // An older account-1 Codex process refreshed the shared runtime file after // Orca selected account-2. Persist the refresh to account-1, then restore - // the selected account in ~/.codex. + // the selected account in runtime CODEX_HOME. writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') service.syncForCurrentSelection() @@ -856,7 +1253,7 @@ describe('CodexRuntimeHomeService', () => { }) it('rejects ambiguous Codex read-back instead of choosing a managed account', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const originalAuth = createCodexAuthJson('same@example.com', 'acct-same', 'original') const refreshedAuth = createCodexAuthJson('same@example.com', 'acct-same', 'refreshed') @@ -918,7 +1315,7 @@ describe('CodexRuntimeHomeService', () => { }) it('rejects runtime read-back without a positive selected-account identity match', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') const accountOnlyAuth = `${JSON.stringify({ @@ -958,7 +1355,7 @@ describe('CodexRuntimeHomeService', () => { }) it('rejects same-email runtime read-back when account ids differ from sparse managed metadata', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const selectedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'selected') const staleLivePtyAuth = createCodexAuthJson('user@example.com', 'acct-stale', 'stale') @@ -993,7 +1390,7 @@ describe('CodexRuntimeHomeService', () => { }) it('reads back same-account refreshes for sparse managed metadata using stored auth identity', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const originalAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'original') const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'refreshed') @@ -1028,7 +1425,7 @@ describe('CodexRuntimeHomeService', () => { }) it('reads back strong account-id refreshes when the runtime auth has no email', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') const refreshedAuth = `${JSON.stringify({ @@ -1068,7 +1465,7 @@ describe('CodexRuntimeHomeService', () => { }) it('rejects unverifiable Codex read-back on first sync after restart', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"tokens":"refreshed-while-down"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, @@ -1103,7 +1500,7 @@ describe('CodexRuntimeHomeService', () => { }) it('reads back verified same-account refreshes on first sync after restart', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original', 1_000) const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed', 2_000) writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') @@ -1136,7 +1533,7 @@ describe('CodexRuntimeHomeService', () => { }) it('rejects older same-account Codex auth on first sync after restart', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() const staleRuntimeAuth = createCodexAuthJson('user@example.com', 'acct-1', 'stale', 1_000) const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed-newer', 2_000) writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') @@ -1169,7 +1566,7 @@ describe('CodexRuntimeHomeService', () => { }) it('does not contaminate the incoming Codex account during account switch', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const managedHomePath1 = createManagedAuth( testState.userDataDir, @@ -1222,7 +1619,7 @@ describe('CodexRuntimeHomeService', () => { }) it('does not carry the reauth read-back skip across Codex account switches', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') @@ -1278,7 +1675,7 @@ describe('CodexRuntimeHomeService', () => { }) it('does not apply inactive-account Codex reauth skip to the active account', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') const account1RefreshedAuth = createCodexAuthJson( @@ -1331,8 +1728,8 @@ describe('CodexRuntimeHomeService', () => { }) it('restores system default when unverified runtime auth appears before deselect', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -1376,8 +1773,8 @@ describe('CodexRuntimeHomeService', () => { }) it('restores system default after same-identity managed Codex refresh on deselect', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8') + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') const externalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'external') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) @@ -1414,8 +1811,8 @@ describe('CodexRuntimeHomeService', () => { }) it('restores system default when stale Codex credentials are rejected on deselect', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8') + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') const staleLivePtyAuth = createCodexAuthJson('stale@example.com', 'acct-stale', 'stale') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) @@ -1452,7 +1849,7 @@ describe('CodexRuntimeHomeService', () => { }) it('keeps external Codex logout when deselecting managed account', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, @@ -1487,9 +1884,46 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(runtimeAuthPath)).toBe(false) }) + it('keeps external system-default logout when managed runtime auth still exists', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"managed"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') + rmSync(getSystemCodexAuthPath(), { force: true }) + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + it('captures a fresh system-default snapshot when re-entering managed mode', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system-1"}\n', 'utf-8') + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-1"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -1518,7 +1952,7 @@ describe('CodexRuntimeHomeService', () => { settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() - writeFileSync(runtimeAuthPath, '{"account":"system-2"}\n', 'utf-8') + writeFileSync(getSystemCodexAuthPath(), '{"account":"system-2"}\n', 'utf-8') settings.activeCodexManagedAccountId = 'account-1' service.syncForCurrentSelection() @@ -1529,7 +1963,7 @@ describe('CodexRuntimeHomeService', () => { }) it('reads back refreshed tokens for the outgoing Codex account before switching', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const account1Original = createCodexAuthJson('one@example.com', 'acct-1', 'one-original') const account1Refreshed = createCodexAuthJson('one@example.com', 'acct-1', 'one-refreshed') @@ -1578,7 +2012,7 @@ describe('CodexRuntimeHomeService', () => { }) it('does not clobber fresh tokens after clearLastWrittenAuthJson', async () => { - const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') const reauthedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'reauthed') @@ -1618,7 +2052,7 @@ describe('CodexRuntimeHomeService', () => { }) it('preserves conflicting legacy session files under deterministic names', async () => { - const runtimeSessionsDir = join(testState.fakeHomeDir, '.codex', 'sessions') + const runtimeSessionsDir = join(getRuntimeCodexHomePath(), 'sessions') mkdirSync(runtimeSessionsDir, { recursive: true }) writeFileSync(join(runtimeSessionsDir, 'session.json'), '{"turns":[1]}', 'utf-8') const managedHomePath = createManagedAuth( diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 3c099ce575b..ecd25ba3906 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -12,12 +12,17 @@ import { rmSync, statSync } from 'node:fs' -import { homedir } from 'node:os' import { dirname, extname, join, parse, relative } from 'node:path' import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/types' import type { Store } from '../persistence' import { writeFileAtomically } from './fs-utils' +import { + getOrcaManagedCodexHomePath, + getSystemCodexHomePath, + syncSystemCodexResourcesIntoManagedHome +} from '../codex/codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' type CodexAuthIdentity = { email: string | null @@ -29,6 +34,16 @@ type CodexSystemDefaultSnapshot = { authJson: string | null } +type CodexRuntimeLogoutMarker = { + systemDefaultAuthJson: string | null + loggedOutAt: number +} + +type CodexRuntimeLogoutMarkerStatus = + | { kind: 'missing' } + | { kind: 'applies' } + | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } + type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' type CodexReadBackMatch = | { @@ -40,12 +55,11 @@ type CodexReadBackMatch = | { kind: 'none' | 'ambiguous' } export class CodexRuntimeHomeService { - // Why: tracks whether auth.json is currently managed by Orca. When null, - // Orca does NOT own auth.json and must not overwrite external changes - // (e.g. user running `codex login` or another auth tool). The snapshot - // restore only fires on the managed→system-default transition. + // Why: tracks whether the runtime auth.json currently mirrors a managed + // account. When null, runtime auth follows the user's system-default + // ~/.codex/auth.json instead of being written back to a managed account. private lastSyncedAccountId: string | null = null - // Why: tracks the auth.json content Orca last wrote to ~/.codex/auth.json. + // Why: tracks the auth.json content Orca last wrote to the runtime CODEX_HOME. // Between syncs, if the file differs, Codex CLI refreshed the token — so // Orca writes back the refreshed token to managed storage before overwriting. // On managed→system-default transition, if the file differs, an external @@ -67,16 +81,21 @@ export class CodexRuntimeHomeService { prepareForCodexLaunch(): string { this.syncForCurrentSelection() + syncSystemCodexResourcesIntoManagedHome() + syncSystemConfigIntoManagedCodexHome() return this.getRuntimeHomePath() } prepareForRateLimitFetch(): string { this.syncForCurrentSelection() + syncSystemCodexResourcesIntoManagedHome() + syncSystemConfigIntoManagedCodexHome() return this.getRuntimeHomePath() } syncForCurrentSelection(): void { const settings = this.store.getSettings() + const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) if (this.lastSyncedAccountId === null) { this.captureSystemDefaultSnapshot({ force: false }) } @@ -98,16 +117,42 @@ export class CodexRuntimeHomeService { if (settings.activeCodexManagedAccountId) { this.store.updateSettings({ activeCodexManagedAccountId: null }) } - // Why: only restore the snapshot when transitioning FROM a managed - // account back to system default. When no managed account was ever - // active, auth.json belongs to the user and Orca must not touch it. - // This prevents overwriting external auth changes (codex login or other - // tools) on every PTY launch / rate-limit fetch. + // Why: only restore the system-default mirror when transitioning FROM a + // managed account. When no managed account was ever active, later syncs + // should mirror the user's current ~/.codex/auth.json instead of + // replaying an old snapshot on every PTY launch / rate-limit fetch. if (this.lastSyncedAccountId !== null) { this.restoreSystemDefaultSnapshot({ detectExternalLogin: outgoingReadBackResult !== 'rejected' }) this.lastSyncedAccountId = null + } else if (!runtimeAuthExistedBeforeSync) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + if (logoutMarkerStatus.kind === 'applies') { + this.lastWrittenAuthJson = null + } else if ( + logoutMarkerStatus.kind === 'system-default-changed' && + logoutMarkerStatus.systemDefaultAuthJson !== null + ) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else if (logoutMarkerStatus.kind === 'system-default-changed') { + // Why: a real ~/.codex logout after a local runtime logout should + // keep runtime auth absent instead of restoring the stale snapshot. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + } else if (this.lastWrittenAuthJson === null) { + // Why: Orca-launched Codex sessions now use an Orca-owned CODEX_HOME + // even when no managed account is selected. Seed that runtime home + // from the user's current system-default auth once so dev/prod Orca + // terminals stay logged in without mutating ~/.codex on startup. + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else { + this.persistRuntimeLogoutMarker() + } + } else { + this.clearRuntimeLogoutMarker() + this.syncRuntimeAuthWithSystemDefault() } return } @@ -129,10 +174,10 @@ export class CodexRuntimeHomeService { this.captureSystemDefaultSnapshot({ force: true }) } - // Why: Codex CLI refreshes expired OAuth tokens and writes them back to - // ~/.codex/auth.json. If we detect the runtime file differs from what Orca - // last wrote, the CLI must have refreshed — so we preserve those tokens - // back to managed storage before overwriting runtime with managed state. + // Why: Codex CLI refreshes expired OAuth tokens in CODEX_HOME/auth.json. + // If we detect the runtime file differs from what Orca last wrote, the CLI + // must have refreshed — so we preserve those tokens back to managed + // storage before overwriting runtime with managed state. if (this.lastSyncedAccountId === activeAccount.id) { if (this.skipNextReadBackForAccountId === activeAccount.id) { this.skipNextReadBackForAccountId = null @@ -303,6 +348,58 @@ export class CodexRuntimeHomeService { ) } + private runtimeAuthMatchesSystemDefaultIdentity( + runtimeAuthContents: string, + systemDefaultAuthContents: string + ): boolean { + const runtimeIdentity = this.readIdentityFromAuthContents(runtimeAuthContents) + const systemDefaultIdentity = this.readIdentityFromAuthContents(systemDefaultAuthContents) + if (!runtimeIdentity || !systemDefaultIdentity) { + return false + } + + // Why: stale managed Codex PTYs share the same runtime home. Only read a + // runtime refresh back into ~/.codex when the auth still claims the same + // system-default identity Orca mirrored earlier. + if ( + systemDefaultIdentity.email && + runtimeIdentity.email && + systemDefaultIdentity.email !== runtimeIdentity.email + ) { + return false + } + if ( + !this.identityFieldMatches( + systemDefaultIdentity.providerAccountId, + runtimeIdentity.providerAccountId + ) + ) { + return false + } + if ( + !this.identityFieldMatches( + systemDefaultIdentity.workspaceAccountId, + runtimeIdentity.workspaceAccountId + ) + ) { + return false + } + + const strongIdentityMatches = Boolean( + (systemDefaultIdentity.providerAccountId && runtimeIdentity.providerAccountId) || + (systemDefaultIdentity.workspaceAccountId && runtimeIdentity.workspaceAccountId) + ) + const emailMatches = Boolean( + systemDefaultIdentity.email && + runtimeIdentity.email && + systemDefaultIdentity.email === runtimeIdentity.email + ) + return ( + strongIdentityMatches || + (emailMatches && !runtimeIdentity.providerAccountId && !runtimeIdentity.workspaceAccountId) + ) + } + private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean { const runtimeFreshness = this.readFreshnessFromAuthContents(runtimeAuthContents) const managedFreshness = this.readFreshnessFromAuthContents(managedAuthContents) @@ -441,9 +538,7 @@ export class CodexRuntimeHomeService { } private getRuntimeHomePath(): string { - const runtimeHomePath = join(homedir(), '.codex') - mkdirSync(runtimeHomePath, { recursive: true }) - return runtimeHomePath + return getOrcaManagedCodexHomePath() } private getRuntimeAuthPath(): string { @@ -454,6 +549,10 @@ export class CodexRuntimeHomeService { return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') } + private getRuntimeLogoutMarkerPath(): string { + return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json') + } + private getRuntimeMetadataDir(): string { const metadataDir = join(app.getPath('userData'), 'codex-runtime-home') mkdirSync(metadataDir, { recursive: true }) @@ -490,8 +589,8 @@ export class CodexRuntimeHomeService { } // Why: migration is intentionally one-shot. Re-importing every startup - // would keep replaying stale managed-home state back into ~/.codex and - // make the shared runtime feel nondeterministic. + // would keep replaying stale managed-home state back into the shared + // runtime and make it feel nondeterministic. writeFileAtomically( this.getMigrationMarkerPath(), `${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n` @@ -618,79 +717,153 @@ export class CodexRuntimeHomeService { return } - const runtimeAuthPath = this.getRuntimeAuthPath() + const runtimeAuthPath = join(getSystemCodexHomePath(), 'auth.json') const snapshot: CodexSystemDefaultSnapshot = { authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null } writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) } - private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { - // Why: detect whether an external tool (e.g. `codex auth login`) overwrote - // auth.json while a managed account was active. If so, that external login - // becomes the new system default — skip the stale snapshot restore. - if (options.detectExternalLogin && this.detectExternalLoginAndUpdateSnapshot()) { + private syncRuntimeAuthWithSystemDefault(): void { + const runtimeAuthPath = this.getRuntimeAuthPath() + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + if (!existsSync(runtimeAuthPath)) { return } + try { + const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') + if (!existsSync(systemDefaultAuthPath)) { + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null + if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + return + } + if ( + mirroredSystemDefaultAuth !== null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + } + return + } + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') + if (runtimeAuth !== systemDefaultAuth) { + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null + if ( + mirroredSystemDefaultAuth !== null && + systemDefaultAuth === mirroredSystemDefaultAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + // Why: system-default Codex now refreshes tokens inside Orca's + // runtime CODEX_HOME. Read that refresh back to ~/.codex so the next + // sync does not overwrite fresh runtime credentials with stale ones. + this.writeSystemDefaultAuth(runtimeAuth) + this.captureSystemDefaultSnapshot({ force: true }) + this.lastWrittenAuthJson = runtimeAuth + return + } + // Why: the unmanaged path used to read ~/.codex directly. Mirror later + // external logins/logouts into Orca's runtime home so ordinary Orca + // Codex sessions keep matching the user's current system-default state. + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth) + } + } catch (error) { + console.warn('[codex-runtime-home] Failed to sync system-default auth:', error) + } + } + + private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { const snapshotPath = this.getSystemDefaultSnapshotPath() - if (!existsSync(snapshotPath)) { - rmSync(this.getRuntimeAuthPath(), { force: true }) + const runtimeAuthPath = this.getRuntimeAuthPath() + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + if (existsSync(systemDefaultAuthPath)) { + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth) + return + } + + if (options.detectExternalLogin && !existsSync(runtimeAuthPath)) { + // Why: once Orca owns the runtime CODEX_HOME, deleting auth.json there is + // a local logout signal for Orca-launched Codex sessions, not a reason to + // rewrite the user's real ~/.codex snapshot back into place. + this.persistRuntimeLogoutMarker() this.lastWrittenAuthJson = null return } + if (options.detectExternalLogin) { + // Why: while a managed account is selected, the runtime auth file exists + // with managed credentials. If ~/.codex/auth.json vanished meanwhile, + // switching back must preserve that external system-default logout. + rmSync(runtimeAuthPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + return + } + + if (!existsSync(snapshotPath)) { + this.captureSystemDefaultSnapshot({ force: true }) + } + const snapshot = this.readSystemDefaultSnapshot(snapshotPath) if (!snapshot) { console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot') rmSync(snapshotPath, { force: true }) - rmSync(this.getRuntimeAuthPath(), { force: true }) - this.lastWrittenAuthJson = null + this.captureSystemDefaultSnapshot({ force: true }) + const refreshedSnapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (!refreshedSnapshot) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + return + } + if (refreshedSnapshot.authJson === null) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + return + } + this.writeRuntimeAuth(refreshedSnapshot.authJson) return } if (snapshot.authJson === null) { - rmSync(this.getRuntimeAuthPath(), { force: true }) + rmSync(runtimeAuthPath, { force: true }) this.lastWrittenAuthJson = null return } this.writeRuntimeAuth(snapshot.authJson) } - // Why: mirrors ClaudeRuntimeAuthService.detectExternalLoginAndUpdateSnapshot(). - // If the runtime auth.json differs from what Orca last wrote, something - // external changed it. That external state should become the new system - // default rather than being overwritten by a potentially stale snapshot. - private detectExternalLoginAndUpdateSnapshot(): boolean { - if (this.lastWrittenAuthJson === null) { - return false - } + private writeSystemDefaultAuth(contents: string): void { + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + mkdirSync(dirname(systemDefaultAuthPath), { recursive: true }) + writeFileAtomically(systemDefaultAuthPath, contents, { mode: 0o600 }) + this.ensureOwnerOnlyMode(systemDefaultAuthPath) + } - const runtimeAuthPath = this.getRuntimeAuthPath() - if (!existsSync(runtimeAuthPath)) { - const snapshotPath = this.getSystemDefaultSnapshotPath() - rmSync(snapshotPath, { force: true }) - this.lastWrittenAuthJson = null - return true - } - - try { - const currentAuth = readFileSync(runtimeAuthPath, 'utf-8') - if (currentAuth === this.lastWrittenAuthJson) { - return false - } - } catch { - return false - } - - const snapshotPath = this.getSystemDefaultSnapshotPath() - rmSync(snapshotPath, { force: true }) + private clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void { + // Why: when the real ~/.codex auth disappears, Orca should treat that as an + // external logout for unmanaged sessions, even if runtime auth had already + // refreshed inside Orca's CODEX_HOME. + rmSync(runtimeAuthPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker() this.lastWrittenAuthJson = null - return true + } + + private readSystemDefaultAuth(): string | null { + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null } private writeRuntimeAuth(contents: string): void { // Why: auth.json contains sensitive credentials. Restrict to owner-only // so other users on a shared Linux/macOS machine cannot read it. + this.clearRuntimeLogoutMarker() if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) { this.ensureOwnerOnlyMode(this.getRuntimeAuthPath()) this.lastWrittenAuthJson = contents @@ -719,6 +892,59 @@ export class CodexRuntimeHomeService { } } + private getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus { + const marker = this.readRuntimeLogoutMarker() + if (!marker) { + return { kind: 'missing' } + } + const systemDefaultAuthJson = this.readSystemDefaultAuth() + if (systemDefaultAuthJson === marker.systemDefaultAuthJson) { + return { kind: 'applies' } + } + this.clearRuntimeLogoutMarker() + return { kind: 'system-default-changed', systemDefaultAuthJson } + } + + private persistRuntimeLogoutMarker(systemDefaultAuthJson = this.readSystemDefaultAuth()): void { + const marker: CodexRuntimeLogoutMarker = { + systemDefaultAuthJson, + loggedOutAt: Date.now() + } + writeFileAtomically(this.getRuntimeLogoutMarkerPath(), `${JSON.stringify(marker, null, 2)}\n`, { + mode: 0o600 + }) + } + + private readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(this.getRuntimeLogoutMarkerPath(), 'utf-8')) as unknown + } catch { + return null + } + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + !('systemDefaultAuthJson' in parsed) || + !('loggedOutAt' in parsed) + ) { + return null + } + const marker = parsed as { systemDefaultAuthJson: unknown; loggedOutAt: unknown } + if ( + (marker.systemDefaultAuthJson !== null && typeof marker.systemDefaultAuthJson !== 'string') || + typeof marker.loggedOutAt !== 'number' + ) { + return null + } + return marker as CodexRuntimeLogoutMarker + } + + private clearRuntimeLogoutMarker(): void { + rmSync(this.getRuntimeLogoutMarkerPath(), { force: true }) + } + private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { let rawContents: string try { diff --git a/src/main/codex-usage/scanner-paths.test.ts b/src/main/codex-usage/scanner-paths.test.ts new file mode 100644 index 00000000000..6c5132a91d2 --- /dev/null +++ b/src/main/codex-usage/scanner-paths.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import type * as NodeOs from 'node:os' +import { join } from 'path' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: homedirMock + } +}) + +import { + getCodexSessionDirectories, + getCodexSessionsDirectory, + listCodexSessionFiles +} from './scanner' + +const originalCodexHome = process.env.CODEX_HOME +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + delete process.env.CODEX_HOME + fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-usage-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-usage-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) +}) + +afterEach(() => { + rmSync(fakeHomeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('getCodexSessionsDirectory', () => { + it('defaults to Orca-managed Codex runtime sessions', () => { + expect(getCodexSessionsDirectory()).toBe( + join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + ) + }) + + it('ignores an ambient CODEX_HOME override', () => { + process.env.CODEX_HOME = '/tmp/explicit-codex-home' + + expect(getCodexSessionsDirectory()).toBe( + join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + ) + }) +}) + +describe('listCodexSessionFiles', () => { + it('scans both Orca-managed and system Codex session homes', async () => { + const runtimeSessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + const systemSessionsDir = join(fakeHomeDir, '.codex', 'sessions') + mkdirSync(runtimeSessionsDir, { recursive: true }) + mkdirSync(systemSessionsDir, { recursive: true }) + const runtimeSessionPath = join(runtimeSessionsDir, 'runtime.jsonl') + const systemSessionPath = join(systemSessionsDir, 'system.jsonl') + writeFileSync(runtimeSessionPath, '{}\n', 'utf-8') + writeFileSync(systemSessionPath, '{}\n', 'utf-8') + + expect(getCodexSessionDirectories()).toEqual([runtimeSessionsDir, systemSessionsDir]) + expect(await listCodexSessionFiles()).toEqual([runtimeSessionPath, systemSessionPath].sort()) + }) +}) diff --git a/src/main/codex-usage/scanner.test.ts b/src/main/codex-usage/scanner.test.ts index d7251b684ea..d060d6c5b2e 100644 --- a/src/main/codex-usage/scanner.test.ts +++ b/src/main/codex-usage/scanner.test.ts @@ -1,4 +1,15 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + import { attributeCodexUsageEvent, parseCodexUsageRecord } from './scanner' describe('parseCodexUsageRecord', () => { diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index 130336204db..94ab6c67659 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -1,11 +1,11 @@ /* eslint-disable max-lines -- Why: Codex discovery, incremental parsing, attribution, and aggregation all depend on the same event-normalization rules. Keeping them together makes the duplicate-snapshot logic easier to audit when usage totals look wrong. */ -import { homedir } from 'os' import { basename, join, win32, posix } from 'path' import { createReadStream } from 'fs' import { realpath, readdir, stat } from 'fs/promises' import { createInterface } from 'readline' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' import type { CodexUsageAttributedEvent, CodexUsageDailyAggregate, @@ -51,7 +51,6 @@ type CodexUsageDeltaResolution = | { kind: 'event'; delta: CodexUsageRawUsage; nextTotals: CodexUsageRawUsage | null } | { kind: 'baseline'; nextTotals: CodexUsageRawUsage } -const DEFAULT_CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions') const YIELD_EVERY_FILES = 10 function ensureNumber(value: unknown): number { @@ -108,19 +107,30 @@ async function walkJsonlFiles(dirPath: string): Promise { } export function getCodexSessionsDirectory(): string { - const codexHome = process.env.CODEX_HOME?.trim() - if (codexHome) { - return join(codexHome, 'sessions') - } - return DEFAULT_CODEX_SESSIONS_DIR + // Why: Orca-launched Codex processes receive an Orca-owned CODEX_HOME, so + // callers that need the primary runtime path should not consult ambient + // shell CODEX_HOME. + return join(getOrcaManagedCodexHomePath(), 'sessions') +} + +export function getCodexSessionDirectories(): string[] { + // Why: upgraded users still have ordinary Codex history under ~/.codex, while + // new Orca-launched sessions are written under Orca's managed runtime home. + return [getCodexSessionsDirectory(), join(getSystemCodexHomePath(), 'sessions')].filter( + (dirPath, index, allDirPaths) => allDirPaths.indexOf(dirPath) === index + ) } export async function listCodexSessionFiles(): Promise { - try { - return (await walkJsonlFiles(getCodexSessionsDirectory())).sort() - } catch { - return [] + const files: string[] = [] + for (const dirPath of getCodexSessionDirectories()) { + try { + files.push(...(await walkJsonlFiles(dirPath))) + } catch { + // Missing or unreadable history in one home should not hide the other. + } } + return [...new Set(files)].sort() } export async function getProcessedFileInfo(filePath: string): Promise { diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts new file mode 100644 index 00000000000..17865439a5d --- /dev/null +++ b/src/main/codex/codex-config-mirror.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as NodeOs from 'node:os' +import { join } from 'node:path' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: homedirMock + } +}) + +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +function getSystemCodexHomePath(): string { + return join(fakeHomeDir, '.codex') +} + +function getSystemConfigPath(): string { + return join(getSystemCodexHomePath(), 'config.toml') +} + +function getRuntimeConfigPath(): string { + return join(userDataDir, 'codex-runtime-home', 'home', 'config.toml') +} + +beforeEach(() => { + fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + mkdirSync(getSystemCodexHomePath(), { recursive: true }) +}) + +afterEach(() => { + rmSync(fakeHomeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('syncSystemConfigIntoManagedCodexHome', () => { + it('seeds a missing runtime config without copying system hook trust', () => { + writeFileSync( + getSystemConfigPath(), + [ + 'model = "system-model"', + '', + '[hooks.state."system-hooks:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:system"', + '', + '[projects."/repo"]', + 'trust_level = "trusted"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('model = "system-model"') + expect(runtimeConfig).toContain('[projects."/repo"]') + expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') + }) + + it('normalizes deprecated codex_hooks feature flag only in runtime config', () => { + writeFileSync( + getSystemConfigPath(), + ['model = "system-model"', '', '[features]', 'codex_hooks = true', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('[features]\nhooks = true') + expect(runtimeConfig).not.toContain('codex_hooks') + expect(readFileSync(getSystemConfigPath(), 'utf-8')).toContain('codex_hooks = true') + }) + + it('drops deprecated codex_hooks when the new hooks flag already exists', () => { + writeFileSync( + getSystemConfigPath(), + ['[features]', 'hooks = true', 'codex_hooks = true', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('[features]\nhooks = true') + expect(runtimeConfig).not.toContain('codex_hooks') + }) + + it('mirrors system config updates while preserving runtime-owned trust sections', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + 'model = "runtime-model"', + '', + '[hooks.state."runtime-hooks:stop:0:0"]', + 'enabled = false', + 'trusted_hash = "sha256:runtime"', + '', + '[projects."/repo"]', + 'trust_level = "trusted"', + '', + '[projects."/runtime-only"]', + 'trust_level = "trusted"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [ + 'model = "system-model"', + '', + '[projects."/repo"] # explicit revocation', + 'trust_level = "untrusted"', + '', + '[projects."/system-only"]', + 'trust_level = "trusted"', + '', + '[hooks.state."system-hooks:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:system"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('model = "system-model"') + expect(runtimeConfig).not.toContain('model = "runtime-model"') + expect(runtimeConfig).toContain('[projects."/repo"]') + expect(runtimeConfig).toContain('[projects."/runtime-only"]') + expect(runtimeConfig).toContain('[projects."/system-only"]') + expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') + expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') + expect(runtimeConfig).toContain('trust_level = "untrusted"') + expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1) + }) + + it('does not treat TOML table headers inside multiline strings as sections', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + '[hooks.state."runtime-hooks:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:runtime"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [ + 'instructions = """', + '[hooks.state."inside-basic-string"]', + 'trusted_hash = "not-a-section"', + '"""', + '', + "literal_instructions = '''", + '[hooks.state."inside-literal-string"]', + "'''", + '', + '[model_providers.openai]', + 'name = "OpenAI"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('[hooks.state."inside-basic-string"]') + expect(runtimeConfig).toContain('[hooks.state."inside-literal-string"]') + expect(runtimeConfig).toContain('[model_providers.openai]') + expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') + }) + + it('does not let triple quotes in comments affect runtime-owned section mirroring', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + '# example: """ in a comment', + "# example: ''' in a comment", + 'model = "runtime-model"', + '', + '[hooks.state."runtime-hooks:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:runtime"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [ + '# system example: """ in a comment', + "# system example: ''' in a comment", + 'model = "system-model"', + '', + '[hooks.state."system-hooks:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:system"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('model = "system-model"') + expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') + expect(runtimeConfig).toContain('trusted_hash = "sha256:runtime"') + expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') + expect(runtimeConfig).not.toContain('trusted_hash = "sha256:system"') + }) + + it('does not create a runtime config when neither system nor runtime config exists', () => { + syncSystemConfigIntoManagedCodexHome() + + expect(existsSync(getRuntimeConfigPath())).toBe(false) + }) +}) diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts new file mode 100644 index 00000000000..3e35511bfa2 --- /dev/null +++ b/src/main/codex/codex-config-mirror.ts @@ -0,0 +1,327 @@ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' + +function getRuntimeCodexConfigTomlPath(): string { + return join(getOrcaManagedCodexHomePath(), 'config.toml') +} + +function getSystemCodexConfigTomlPath(): string { + return join(getSystemCodexHomePath(), 'config.toml') +} + +export function syncSystemConfigIntoManagedCodexHome(): void { + try { + syncSystemConfigIntoManagedCodexHomeUnsafe() + } catch (error) { + console.warn('[codex-config] Failed to mirror system Codex config:', error) + } +} + +function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { + const systemConfigPath = getSystemCodexConfigTomlPath() + const runtimeConfigPath = getRuntimeCodexConfigTomlPath() + const systemConfigExists = existsSync(systemConfigPath) + const runtimeConfigExists = existsSync(runtimeConfigPath) + if (!systemConfigExists && !runtimeConfigExists) { + return + } + + const systemConfig = normalizeDeprecatedCodexHookFeatureFlag( + systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '' + ) + if (!runtimeConfigExists) { + // Why: trust blocks reference a hooks.json path, so system-home hook trust + // entries are not valid in Orca's runtime CODEX_HOME until install remaps them. + writeFileAtomically(runtimeConfigPath, stripRuntimeOwnedTomlSections(systemConfig)) + return + } + + const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') + const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig) + if (mergedConfig !== runtimeConfig) { + writeFileAtomically(runtimeConfigPath, mergedConfig) + } +} + +function normalizeDeprecatedCodexHookFeatureFlag(config: string): string { + if (!config.includes('codex_hooks')) { + return config + } + + const lines = config.split('\n') + const featureSections: { start: number; end: number }[] = [] + let featureStart: number | null = null + + for (let index = 0; index <= lines.length; index += 1) { + const line = lines[index] + const isHeader = line === undefined || /^[ \t]*\[[^\]]+\][ \t]*(?:#.*)?$/.test(line) + if (!isHeader) { + continue + } + + if (featureStart !== null) { + featureSections.push({ start: featureStart, end: index }) + featureStart = null + } + if (line !== undefined && /^[ \t]*\[features\][ \t]*(?:#.*)?$/.test(line)) { + featureStart = index + } + } + + for (const section of featureSections.reverse()) { + normalizeFeatureSectionLines(lines, section.start + 1, section.end) + } + return lines.join('\n') +} + +function normalizeFeatureSectionLines(lines: string[], start: number, end: number): void { + const deprecatedIndexes: number[] = [] + let hasHooksKey = false + for (let index = start; index < end; index += 1) { + const line = lines[index] ?? '' + if (/^[ \t]*hooks[ \t]*=/.test(line)) { + hasHooksKey = true + } + if (/^[ \t]*codex_hooks[ \t]*=/.test(line)) { + deprecatedIndexes.push(index) + } + } + if (deprecatedIndexes.length === 0) { + return + } + + if (!hasHooksKey) { + const firstDeprecatedIndex = deprecatedIndexes.shift() + if (firstDeprecatedIndex !== undefined) { + // Why: Codex 0.133 warns on the old key. Mirror into Orca's runtime + // config using the new key without rewriting the user's real config. + lines[firstDeprecatedIndex] = lines[firstDeprecatedIndex]!.replace( + /^([ \t]*)codex_hooks([ \t]*=)/, + '$1hooks$2' + ) + } + } + + for (const index of deprecatedIndexes.reverse()) { + lines.splice(index, 1) + } +} + +function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: string): string { + const runtimeSections = getTomlSections(runtimeConfig) + const runtimeProjectHeaders = new Set( + runtimeSections + .filter((section) => isRuntimeProjectTomlSection(section.header)) + .map((section) => getTomlSectionHeaderKey(section.header)) + ) + const systemUntrustedProjectHeaders = new Set( + getTomlSections(systemConfig) + .filter((section) => isRuntimeProjectTomlSection(section.header)) + .filter((section) => getProjectTrustLevel(section.block) === 'untrusted') + .map((section) => getTomlSectionHeaderKey(section.header)) + ) + // Why: ordinary Codex settings should mirror ~/.codex exactly; runtime hook + // trust and project trust are written under Orca's managed CODEX_HOME and + // must survive the copy unless the user explicitly revoked project trust in + // the system config. + return joinTomlBlocks([ + stripRuntimeOwnedTomlSections(systemConfig, runtimeProjectHeaders), + ...runtimeSections + .filter((section) => isRuntimePreservedTomlSection(section.header)) + .filter( + (section) => + !isRuntimeProjectTomlSection(section.header) || + !systemUntrustedProjectHeaders.has(getTomlSectionHeaderKey(section.header)) + ) + .map((section) => section.block) + ]) +} + +type TomlSection = { + header: string + block: string + start: number +} + +type TomlMultilineState = { + basic: boolean + literal: boolean +} + +type TomlMultilineMode = 'basic' | 'literal' | null + +function stripRuntimeOwnedTomlSections( + config: string, + runtimeProjectHeaders = new Set() +): string { + const lines = config.split('\n') + const sections = getTomlSections(config) + const firstSectionIndex = sections[0]?.start ?? -1 + const preamble = firstSectionIndex === -1 ? config : lines.slice(0, firstSectionIndex).join('\n') + return joinTomlBlocks([ + preamble, + ...sections + .filter((section) => !isRuntimeHookTrustTomlSection(section.header)) + .filter( + (section) => + !isRuntimeProjectTomlSection(section.header) || + !runtimeProjectHeaders.has(getTomlSectionHeaderKey(section.header)) || + getProjectTrustLevel(section.block) === 'untrusted' + ) + .map((section) => section.block) + ]) +} + +function getTomlSections(config: string): TomlSection[] { + const lines = config.split('\n') + const sections: TomlSection[] = [] + let sectionStart = -1 + let sectionHeader: string | null = null + let multilineState: TomlMultilineState = { basic: false, literal: false } + + for (let index = 0; index < lines.length; index += 1) { + const header = isInsideTomlMultilineString(multilineState) + ? null + : getTomlTableHeader(lines[index] ?? '') + if (!header) { + multilineState = updateTomlMultilineState(multilineState, lines[index] ?? '') + continue + } + + if (sectionStart !== -1) { + sections.push({ + header: sectionHeader ?? '', + block: lines.slice(sectionStart, index).join('\n'), + start: sectionStart + }) + } + sectionStart = index + sectionHeader = header + multilineState = updateTomlMultilineState(multilineState, lines[index] ?? '') + } + + if (sectionStart !== -1) { + sections.push({ + header: sectionHeader ?? '', + block: lines.slice(sectionStart).join('\n'), + start: sectionStart + }) + } + return sections +} + +function isRuntimePreservedTomlSection(header: string): boolean { + return isRuntimeHookTrustTomlSection(header) || isRuntimeProjectTomlSection(header) +} + +function isRuntimeHookTrustTomlSection(header: string): boolean { + return header.trimStart().startsWith('[hooks.state.') +} + +function isRuntimeProjectTomlSection(header: string): boolean { + return header.trimStart().startsWith('[projects.') +} + +function getTomlSectionHeaderKey(header: string): string { + return header.trim() +} + +function getProjectTrustLevel(block: string): 'trusted' | 'untrusted' | null { + const match = + /^[ \t]*trust_level[ \t]*=[ \t]*(?:"(trusted|untrusted)"|'(trusted|untrusted)')[ \t\r]*(?:#.*)?$/m.exec( + block + ) + const trustLevel = match?.[1] ?? match?.[2] ?? null + return trustLevel === 'trusted' || trustLevel === 'untrusted' ? trustLevel : null +} + +function joinTomlBlocks(blocks: string[]): string { + const normalizedBlocks = blocks.map((block) => block.trim()).filter((block) => block.length > 0) + return normalizedBlocks.length === 0 ? '' : `${normalizedBlocks.join('\n\n')}\n` +} + +function getTomlTableHeader(line: string): string | null { + const match = /^(\s*\[\[?.+\]\]?\s*)(?:#.*)?$/.exec(line) + return match?.[1] ?? null +} + +function isInsideTomlMultilineString(state: TomlMultilineState): boolean { + return state.basic || state.literal +} + +function updateTomlMultilineState(state: TomlMultilineState, line: string): TomlMultilineState { + let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null + let index = 0 + while (index < line.length) { + if (mode === 'basic') { + if (line[index] === '\\') { + index += 2 + continue + } + if (line.startsWith('"""', index)) { + mode = null + index += 3 + continue + } + index += 1 + continue + } + if (mode === 'literal') { + if (line.startsWith("'''", index)) { + mode = null + index += 3 + continue + } + index += 1 + continue + } + + const char = line[index] + if (char === '#') { + break + } + if (line.startsWith('"""', index)) { + mode = 'basic' + index += 3 + continue + } + if (line.startsWith("'''", index)) { + mode = 'literal' + index += 3 + continue + } + if (char === '"') { + index = skipTomlBasicString(line, index + 1) + continue + } + if (char === "'") { + index = skipTomlLiteralString(line, index + 1) + continue + } + index += 1 + } + return { basic: mode === 'basic', literal: mode === 'literal' } +} + +function skipTomlBasicString(line: string, startIndex: number): number { + let index = startIndex + while (index < line.length) { + const char = line[index] + if (char === '\\') { + index += 2 + continue + } + if (char === '"') { + return index + 1 + } + index += 1 + } + return index +} + +function skipTomlLiteralString(line: string, startIndex: number): number { + const endIndex = line.indexOf("'", startIndex) + return endIndex === -1 ? line.length : endIndex + 1 +} diff --git a/src/main/codex/codex-home-paths.test.ts b/src/main/codex/codex-home-paths.test.ts new file mode 100644 index 00000000000..139e06cae66 --- /dev/null +++ b/src/main/codex/codex-home-paths.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import { join } from 'node:path' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +const { fsMockState } = vi.hoisted(() => ({ + fsMockState: { failSymlink: false } +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + symlinkSync: (...args: Parameters) => { + if (fsMockState.failSymlink) { + throw new Error('symlink disabled for test') + } + return actual.symlinkSync(...args) + } + } +}) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: homedirMock + } +}) + +import { syncSystemCodexResourcesIntoManagedHome } from './codex-home-paths' + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +function getSystemCodexHomePath(): string { + return join(fakeHomeDir, '.codex') +} + +function getRuntimeCodexHomePath(): string { + return join(userDataDir, 'codex-runtime-home', 'home') +} + +function normalizeLinkTarget(linkTarget: string): string { + return process.platform === 'win32' + ? linkTarget.replace(/^\\\\\?\\/, '').toLowerCase() + : linkTarget +} + +function expectSymbolicLinkTargetIfLinked(targetPath: string, sourcePath: string): void { + if (!lstatSync(targetPath).isSymbolicLink()) { + return + } + expect(normalizeLinkTarget(readlinkSync(targetPath))).toBe(normalizeLinkTarget(sourcePath)) +} + +function mockElectronAppPaths(): void { + vi.doMock('electron', () => ({ + app: { + getPath: getPathMock + } + })) +} + +beforeEach(() => { + mockElectronAppPaths() + fsMockState.failSymlink = false + fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-resource-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-resource-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + mkdirSync(getSystemCodexHomePath(), { recursive: true }) +}) + +afterEach(() => { + rmSync(fakeHomeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('syncSystemCodexResourcesIntoManagedHome', () => { + it('uses ORCA_USER_DATA_PATH when Electron cannot be required', async () => { + vi.resetModules() + vi.doMock('electron', () => { + throw new Error('electron unavailable in packaged CLI') + }) + const previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + try { + const { getOrcaManagedCodexHomePath: getCliSafeManagedPath } = + await import('./codex-home-paths') + + expect(getCliSafeManagedPath()).toBe(join(userDataDir, 'codex-runtime-home', 'home')) + } finally { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + mockElectronAppPaths() + vi.resetModules() + } + }) + + it('mirrors only user resource entries into the managed runtime home', () => { + mkdirSync(join(getSystemCodexHomePath(), 'skills', 'review'), { recursive: true }) + mkdirSync(join(getSystemCodexHomePath(), 'plugins'), { recursive: true }) + mkdirSync(join(getSystemCodexHomePath(), 'sessions'), { recursive: true }) + writeFileSync(join(getSystemCodexHomePath(), 'skills', 'review', 'SKILL.md'), 'skill\n') + writeFileSync(join(getSystemCodexHomePath(), 'plugins', 'plugin.json'), '{}\n') + writeFileSync(join(getSystemCodexHomePath(), 'auth.json'), '{"account":"system"}\n') + writeFileSync(join(getSystemCodexHomePath(), 'hooks.json'), '{"hooks":{}}\n') + writeFileSync(join(getSystemCodexHomePath(), 'history.jsonl'), '{}\n') + + syncSystemCodexResourcesIntoManagedHome() + + const runtimeSkillsPath = join(getRuntimeCodexHomePath(), 'skills') + const runtimePluginsPath = join(getRuntimeCodexHomePath(), 'plugins') + expect(readFileSync(join(runtimeSkillsPath, 'review', 'SKILL.md'), 'utf-8')).toBe('skill\n') + expect(readFileSync(join(runtimePluginsPath, 'plugin.json'), 'utf-8')).toBe('{}\n') + expectSymbolicLinkTargetIfLinked(runtimeSkillsPath, join(getSystemCodexHomePath(), 'skills')) + expect(existsSync(join(getRuntimeCodexHomePath(), 'sessions'))).toBe(false) + expect(existsSync(join(getRuntimeCodexHomePath(), 'auth.json'))).toBe(false) + expect(existsSync(join(getRuntimeCodexHomePath(), 'hooks.json'))).toBe(false) + expect(existsSync(join(getRuntimeCodexHomePath(), 'history.jsonl'))).toBe(false) + }) + + it('does not replace an existing runtime-owned resource entry', () => { + mkdirSync(join(getSystemCodexHomePath(), 'skills'), { recursive: true }) + mkdirSync(join(getRuntimeCodexHomePath(), 'skills'), { recursive: true }) + writeFileSync(join(getSystemCodexHomePath(), 'skills', 'system.md'), 'system\n') + writeFileSync(join(getRuntimeCodexHomePath(), 'skills', 'runtime.md'), 'runtime\n') + + syncSystemCodexResourcesIntoManagedHome() + + const runtimeSkillsPath = join(getRuntimeCodexHomePath(), 'skills') + expect(lstatSync(runtimeSkillsPath).isSymbolicLink()).toBe(false) + expect(readFileSync(join(runtimeSkillsPath, 'runtime.md'), 'utf-8')).toBe('runtime\n') + expect(existsSync(join(runtimeSkillsPath, 'system.md'))).toBe(false) + }) + + it('removes owned symlinks for deleted system resources without touching unrelated runtime links', () => { + const systemSkillsPath = join(getSystemCodexHomePath(), 'skills') + const runtimeSkillsPath = join(getRuntimeCodexHomePath(), 'skills') + const externalPluginsPath = join(userDataDir, 'external-plugins') + const runtimePluginsPath = join(getRuntimeCodexHomePath(), 'plugins') + mkdirSync(systemSkillsPath, { recursive: true }) + mkdirSync(externalPluginsPath, { recursive: true }) + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writeFileSync(join(systemSkillsPath, 'system.md'), 'system\n') + writeFileSync(join(externalPluginsPath, 'runtime.md'), 'runtime\n') + symlinkSync( + externalPluginsPath, + runtimePluginsPath, + process.platform === 'win32' ? 'junction' : undefined + ) + + syncSystemCodexResourcesIntoManagedHome() + expect(lstatSync(runtimeSkillsPath).isSymbolicLink()).toBe(true) + expectSymbolicLinkTargetIfLinked(runtimeSkillsPath, systemSkillsPath) + + rmSync(systemSkillsPath, { recursive: true, force: true }) + syncSystemCodexResourcesIntoManagedHome() + + expect(() => lstatSync(runtimeSkillsPath)).toThrow() + expect(lstatSync(runtimePluginsPath).isSymbolicLink()).toBe(true) + expectSymbolicLinkTargetIfLinked(runtimePluginsPath, externalPluginsPath) + expect(readFileSync(join(runtimePluginsPath, 'runtime.md'), 'utf-8')).toBe('runtime\n') + }) + + it('refreshes owned fallback copies when symlinks are unavailable', () => { + fsMockState.failSymlink = true + const systemProfilePath = join(getSystemCodexHomePath(), 'profile-v2') + const runtimeProfilePath = join(getRuntimeCodexHomePath(), 'profile-v2') + writeFileSync(systemProfilePath, 'first\n', 'utf-8') + + syncSystemCodexResourcesIntoManagedHome() + writeFileSync(systemProfilePath, 'second\n', 'utf-8') + syncSystemCodexResourcesIntoManagedHome() + + expect(lstatSync(runtimeProfilePath).isSymbolicLink()).toBe(false) + expect(readFileSync(runtimeProfilePath, 'utf-8')).toBe('second\n') + }) +}) diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts new file mode 100644 index 00000000000..80e039d753c --- /dev/null +++ b/src/main/codex/codex-home-paths.ts @@ -0,0 +1,229 @@ +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + rmdirSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' + +const CODEX_SYSTEM_RESOURCE_ENTRIES = [ + 'skills', + 'plugins', + 'plugin-state', + 'profile-v2', + 'themes', + 'prompts' +] as const + +export function getSystemCodexHomePath(): string { + return join(homedir(), '.codex') +} + +export function getOrcaManagedCodexHomePath(): string { + const managedHomePath = join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') + mkdirSync(managedHomePath, { recursive: true }) + return managedHomePath +} + +function getOrcaUserDataPath(): string { + if (process.env.ORCA_USER_DATA_PATH) { + return process.env.ORCA_USER_DATA_PATH + } + // Why: CLI hook commands import this module outside Electron. Mirror the CLI + // runtime metadata path so offline hook status/on/off uses the same userData. + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'orca') + } + if (process.platform === 'win32') { + return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'orca') + } + return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'orca') +} + +export function syncSystemCodexResourcesIntoManagedHome(): void { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = getOrcaManagedCodexHomePath() + for (const entryName of CODEX_SYSTEM_RESOURCE_ENTRIES) { + linkSystemCodexResource(systemHomePath, managedHomePath, entryName) + } +} + +function linkSystemCodexResource( + systemHomePath: string, + managedHomePath: string, + entryName: string +): void { + const sourcePath = join(systemHomePath, entryName) + const targetPath = join(managedHomePath, entryName) + if (!existsSync(sourcePath)) { + removeCopiedResourceIfOwned(targetPath, managedHomePath, entryName, sourcePath) + return + } + + if (targetAlreadyPointsToSource(targetPath, sourcePath)) { + clearCopiedResourceMarker(managedHomePath, entryName) + return + } + const shouldRefreshFallbackCopy = targetIsOwnedFallbackCopy( + targetPath, + managedHomePath, + entryName, + sourcePath + ) + if (existsSync(targetPath) && !shouldRefreshFallbackCopy) { + return + } + if (shouldRefreshFallbackCopy) { + rmSync(targetPath, { recursive: true, force: true }) + } + + try { + const sourceStat = lstatSync(sourcePath) + symlinkSync( + sourcePath, + targetPath, + sourceStat.isDirectory() && process.platform === 'win32' ? 'junction' : undefined + ) + clearCopiedResourceMarker(managedHomePath, entryName) + } catch (error) { + try { + rmSync(targetPath, { recursive: true, force: true }) + // Why: Windows can reject file symlinks outside developer mode. Copy is + // a fallback for launch-time resources; mark ownership so later syncs can + // refresh the copy without touching user-created runtime resources. + cpSync(sourcePath, targetPath, { recursive: true, force: false, errorOnExist: true }) + markCopiedResource(managedHomePath, entryName, sourcePath) + } catch { + console.warn('[codex-home] Failed to link system Codex resource:', entryName, error) + } + } +} + +function targetAlreadyPointsToSource(targetPath: string, sourcePath: string): boolean { + try { + return ( + lstatSync(targetPath).isSymbolicLink() && + linkTargetsMatch(readlinkSync(targetPath), sourcePath) + ) + } catch { + return false + } +} + +function linkTargetsMatch(actualTarget: string, expectedTarget: string): boolean { + if (process.platform !== 'win32') { + return actualTarget === expectedTarget + } + return normalizeWindowsLinkTarget(actualTarget) === normalizeWindowsLinkTarget(expectedTarget) +} + +function normalizeWindowsLinkTarget(linkTarget: string): string { + return linkTarget.replace(/^\\\\\?\\/, '').toLowerCase() +} + +function getResourceCopyMarkerPath(managedHomePath: string, entryName: string): string { + return join(managedHomePath, '.orca-resource-copies', `${entryName}.json`) +} + +function markCopiedResource(managedHomePath: string, entryName: string, sourcePath: string): void { + const markerPath = getResourceCopyMarkerPath(managedHomePath, entryName) + mkdirSync(dirname(markerPath), { recursive: true }) + writeFileSync(markerPath, `${JSON.stringify({ sourcePath }, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600 + }) +} + +function readCopiedResourceSourcePath(managedHomePath: string, entryName: string): string | null { + try { + const parsed: unknown = JSON.parse( + readFileSync(getResourceCopyMarkerPath(managedHomePath, entryName), 'utf-8') + ) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const sourcePath = 'sourcePath' in parsed ? parsed.sourcePath : null + return typeof sourcePath === 'string' ? sourcePath : null + } catch { + return null + } +} + +function clearCopiedResourceMarker(managedHomePath: string, entryName: string): void { + rmSync(getResourceCopyMarkerPath(managedHomePath, entryName), { force: true }) +} + +function targetIsOwnedFallbackCopy( + targetPath: string, + managedHomePath: string, + entryName: string, + sourcePath: string +): boolean { + if (readCopiedResourceSourcePath(managedHomePath, entryName) !== sourcePath) { + return false + } + try { + return existsSync(targetPath) && !lstatSync(targetPath).isSymbolicLink() + } catch { + return false + } +} + +function removeCopiedResourceIfOwned( + targetPath: string, + managedHomePath: string, + entryName: string, + sourcePath: string +): void { + if (removeSymlinkedResourceIfOwned(targetPath, sourcePath)) { + clearCopiedResourceMarker(managedHomePath, entryName) + return + } + if (!targetIsOwnedFallbackCopy(targetPath, managedHomePath, entryName, sourcePath)) { + return + } + rmSync(targetPath, { recursive: true, force: true }) + clearCopiedResourceMarker(managedHomePath, entryName) +} + +function removeSymlinkedResourceIfOwned(targetPath: string, sourcePath: string): boolean { + try { + if (!lstatSync(targetPath).isSymbolicLink()) { + return false + } + if (!linkTargetsMatch(readlinkSync(targetPath), sourcePath)) { + return false + } + return removeSymlinkEntry(targetPath) + } catch { + return false + } +} + +function removeSymlinkEntry(targetPath: string): boolean { + try { + // Why: recursive rm can leave a broken directory symlink behind; unlink the + // link entry itself so deleted system resources do not linger in runtime home. + unlinkSync(targetPath) + return true + } catch { + if (process.platform !== 'win32') { + return false + } + } + + try { + rmdirSync(targetPath) + return true + } catch { + return false + } +} diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index 447294af058..763dcca5870 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -1,6 +1,14 @@ /* eslint-disable max-lines -- Why: this suite keeps the hash fixture, TOML edit edge cases, and trust-state parser regressions together so Codex compatibility failures are easy to audit. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { @@ -219,6 +227,23 @@ describe('computeTrustKey', () => { }) ).toBe('/Users/thebr/.codex/hooks.json:pre_tool_use:0:0') }) + + it('uses Codex canonicalized source paths when hooks.json exists', () => { + const nestedDir = join(tmpDir, 'nested') + mkdirSync(nestedDir) + const hooksPath = join(nestedDir, '..', 'hooks.json') + writeFileSync(hooksPath, '{"hooks":{}}\n', 'utf-8') + + expect( + computeTrustKey({ + sourcePath: hooksPath, + eventLabel: 'user_prompt_submit', + groupIndex: 0, + handlerIndex: 0, + command: 'irrelevant' + }) + ).toBe(`${realpathSync.native(hooksPath)}:user_prompt_submit:0:0`) + }) }) describe('upsertHookTrustEntries', () => { @@ -642,6 +667,20 @@ describe('upsertProjectTrustLevel', () => { ) }) + it('uses Codex canonicalized project paths when the project exists', () => { + const nestedDir = join(tmpDir, 'nested') + const projectDir = join(tmpDir, 'project') + mkdirSync(nestedDir) + mkdirSync(projectDir) + const aliasedProjectPath = join(nestedDir, '..', 'project') + const trustedPath = realpathSync.native(aliasedProjectPath) + const trustedTomlPath = trustedPath.replaceAll('\\', '\\\\').replaceAll('"', '\\"') + + expect(upsertProjectTrustLevelInContent('', aliasedProjectPath, 'trusted')).toBe( + [`[projects."${trustedTomlPath}"]`, 'trust_level = "trusted"', ''].join('\n') + ) + }) + it('updates an existing project block without touching unrelated keys', () => { const original = [ 'model = "gpt-5.5"', diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index d619bb9bcf7..6f4f279d9f6 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -5,6 +5,7 @@ import { mkdirSync, readFileSync, renameSync, + realpathSync, unlinkSync, writeFileSync } from 'fs' @@ -101,7 +102,18 @@ export function computeTrustedHash(entry: CodexTrustEntry): string { } export function computeTrustKey(entry: CodexTrustEntry): string { - return `${entry.sourcePath}:${entry.eventLabel}:${entry.groupIndex}:${entry.handlerIndex}` + return `${getCodexCanonicalTrustPath(entry.sourcePath)}:${entry.eventLabel}:${entry.groupIndex}:${entry.handlerIndex}` +} + +export function getCodexCanonicalTrustPath(sourcePath: string): string { + try { + // Why: Codex canonicalizes trust paths before building config keys. On + // macOS, /var is a symlink to /private/var; trusting the raw path still + // leaves the TUI in review/trust prompts. + return realpathSync.native(sourcePath) + } catch { + return sourcePath + } } export function parseTrustKey(key: string): { @@ -232,13 +244,14 @@ export function upsertProjectTrustLevelInContent( ): string { const existing = existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent - const headerPattern = buildProjectHeaderPattern(projectPath) + const trustedProjectPath = getCodexCanonicalTrustPath(projectPath) + const headerPattern = buildProjectHeaderPattern(trustedProjectPath) const match = headerPattern.exec(existing) const eol = existing.includes('\r\n') ? '\r\n' : '\n' const trustLine = `trust_level = "${trustLevel}"` if (!match) { - const block = [`[projects."${escapeTomlString(projectPath)}"]`, trustLine].join(eol) + const block = [`[projects."${escapeTomlString(trustedProjectPath)}"]`, trustLine].join(eol) if (existing.length === 0) { return `${block}${eol}` } diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index 4057d22a182..441f5d508e6 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -1,8 +1,20 @@ +/* eslint-disable max-lines -- Why: this suite shares mocked homedir/userData setup across local/system Codex hook install, trust, and legacy-cleanup regressions. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'fs' import { tmpdir } from 'os' import type * as Os from 'os' import { join } from 'path' +import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' +import { upsertHookTrustEntriesInContent } from './config-toml-trust' const { getPathMock, homedirMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>(), @@ -27,10 +39,13 @@ import { CodexHookService } from './hook-service' let tmpHome: string let userDataDir: string +let previousUserDataPath: string | undefined beforeEach(() => { tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir homedirMock.mockReturnValue(tmpHome) getPathMock.mockImplementation((name: string) => { if (name === 'userData') { @@ -43,18 +58,53 @@ beforeEach(() => { afterEach(() => { rmSync(tmpHome, { recursive: true, force: true }) rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } vi.clearAllMocks() }) +function escapeTomlBasicString(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"') +} + +function hookTrustHeader(key: string): string { + return `[hooks.state."${escapeTomlBasicString(canonicalizeHookTrustKeyForTest(key))}"]` +} + +function canonicalizeHookTrustKeyForTest(key: string): string { + const lastColon = key.lastIndexOf(':') + const secondLast = lastColon === -1 ? -1 : key.lastIndexOf(':', lastColon - 1) + const thirdLast = secondLast === -1 ? -1 : key.lastIndexOf(':', secondLast - 1) + if (thirdLast === -1) { + return key + } + const sourcePath = key.slice(0, thirdLast) + try { + return `${realpathSync.native(sourcePath)}${key.slice(thirdLast)}` + } catch { + return key + } +} + describe('CodexHookService', () => { it('installs PermissionRequest with trust so Codex approval prompts reach Orca', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + join(systemCodexHome, 'config.toml'), + 'model = "gpt-5.2-codex"\napproval_policy = "on-request"\n', + 'utf-8' + ) + const status = new CodexHookService().install() expect(status.state).toBe('installed') - const hooksConfig = JSON.parse( - readFileSync(join(tmpHome, '.codex', 'hooks.json'), 'utf-8') - ) as { + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const hooksConfig = JSON.parse(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')) as { hooks: Record } @@ -71,78 +121,696 @@ describe('CodexHookService', () => { expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('agent-hooks') expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook') - const trustConfig = readFileSync(join(tmpHome, '.codex', 'config.toml'), 'utf-8') + const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(trustConfig).toContain('model = "gpt-5.2-codex"') + expect(trustConfig).toContain('approval_policy = "on-request"') expect(trustConfig).toContain(':permission_request:0:0') }) - it('installs Orca status hooks in the Codex profile instead of global hooks.json', () => { - const service = new CodexHookService() - const status = service.installProfile() + it('keeps hooks isolated by Orca userData instead of mutating system ~/.codex', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + const existingSystemHooks = '{"hooks":{"Stop":[{"hooks":[{"command":"user-hook"}]}]}}\n' + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync(systemHooksPath, existingSystemHooks, 'utf-8') - expect(status.state).toBe('installed') + const devUserDataDir = mkdtempSync(join(tmpdir(), 'orca-dev-codex-user-data-')) + const prodUserDataDir = mkdtempSync(join(tmpdir(), 'orca-prod-codex-user-data-')) + try { + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return devUserDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + process.env.ORCA_USER_DATA_PATH = devUserDataDir + expect(new CodexHookService().install().state).toBe('installed') - const profileConfig = readFileSync( - join(tmpHome, '.codex', 'orca-agent-status.config.toml'), - 'utf-8' - ) - expect(profileConfig).toContain('# BEGIN ORCA AGENT STATUS HOOKS') - expect(profileConfig).toContain('[[hooks.PermissionRequest]]') - expect(profileConfig).toContain(':permission_request:0:0') - expect(profileConfig).toContain('codex-hook') - expect(service.getStatus().state).toBe('not_installed') + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return prodUserDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + process.env.ORCA_USER_DATA_PATH = prodUserDataDir + expect(new CodexHookService().install().state).toBe('installed') + + const devHooksPath = join(devUserDataDir, 'codex-runtime-home', 'home', 'hooks.json') + const prodHooksPath = join(prodUserDataDir, 'codex-runtime-home', 'home', 'hooks.json') + expect(existsSync(devHooksPath)).toBe(true) + expect(existsSync(prodHooksPath)).toBe(true) + const devHooks = JSON.parse(readFileSync(devHooksPath, 'utf-8')) as { + hooks: Record + } + const prodHooks = JSON.parse(readFileSync(prodHooksPath, 'utf-8')) as { + hooks: Record + } + expect(devHooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toBe('user-hook') + expect(prodHooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toBe('user-hook') + expect( + devHooks.hooks.Stop?.some((definition) => + definition.hooks?.[0]?.command?.includes('codex-hook') + ) + ).toBe(true) + expect( + prodHooks.hooks.Stop?.some((definition) => + definition.hooks?.[0]?.command?.includes('codex-hook') + ) + ).toBe(true) + expect(readFileSync(systemHooksPath, 'utf-8')).toBe(existingSystemHooks) + } finally { + process.env.ORCA_USER_DATA_PATH = userDataDir + rmSync(devUserDataDir, { recursive: true, force: true }) + rmSync(prodUserDataDir, { recursive: true, force: true }) + } }) - it('keeps the Codex profile hook-only and preserves user provider config', () => { - const service = new CodexHookService() - const baseConfigPath = join(tmpHome, '.codex', 'config.toml') - mkdirSync(join(tmpHome, '.codex'), { recursive: true }) - const baseConfig = [ - 'model_provider = "amazon-bedrock"', - '', - '[model_providers.amazon-bedrock]', - 'name = "Amazon Bedrock"', - 'base_url = "https://bedrock-runtime.us-west-2.amazonaws.com"', - 'env_key = "AWS_BEARER_TOKEN_BEDROCK"', - '' - ].join('\n') - writeFileSync(baseConfigPath, baseConfig) - - const status = service.installProfile() - - expect(status.state).toBe('installed') - expect(readFileSync(baseConfigPath, 'utf-8')).toBe(baseConfig) - const profileConfig = readFileSync( - join(tmpHome, '.codex', 'orca-agent-status.config.toml'), + it('mirrors trusted system user hook approvals into the runtime CODEX_HOME', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { + matcher: '*', + hooks: [ + { + type: 'command', + command: 'user-hook', + timeout: 12, + async: true, + statusMessage: 'Running user hook' + } + ] + } + ] + } + }, + null, + 2 + )}\n`, 'utf-8' ) - expect(profileConfig).toContain('# BEGIN ORCA AGENT STATUS HOOKS') - expect(profileConfig).not.toContain('model_provider') - expect(profileConfig).not.toContain('model_providers') - expect(profileConfig).not.toContain('env_key') + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'user-hook', + timeoutSec: 12, + async: true, + matcher: '*', + statusMessage: 'Running user hook' + } + ]), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record< + string, + { matcher?: string; hooks?: { command?: string; statusMessage?: string }[] }[] + > + } + expect(runtimeHooks.hooks.Stop?.[0]?.matcher).toBe('*') + expect(runtimeHooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toBe('user-hook') + expect(runtimeHooks.hooks.Stop?.[0]?.hooks?.[0]?.statusMessage).toBe('Running user hook') + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`)) }) - it('removes only the Orca-managed Codex profile block', () => { - const service = new CodexHookService() - service.installProfile() - const profilePath = join(tmpHome, '.codex', 'orca-agent-status.config.toml') - const withUserConfig = `${readFileSync(profilePath, 'utf-8')}\nmodel = "gpt-5.5"\n` - writeFileSync(profilePath, withUserConfig) + it('mirrors compact-event user hook approvals and disabled trust entries', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + PreCompact: [{ hooks: [{ type: 'command', command: 'pre-compact-user' }] }], + PostCompact: [{ hooks: [{ type: 'command', command: 'post-compact-disabled' }] }] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + const disabledPostCompactHeader = hookTrustHeader(`${systemHooksPath}:post_compact:0:0`) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'pre_compact', + groupIndex: 0, + handlerIndex: 0, + command: 'pre-compact-user' + }, + { + sourcePath: systemHooksPath, + eventLabel: 'post_compact', + groupIndex: 0, + handlerIndex: 0, + command: 'post-compact-disabled' + } + ]).replace( + `${disabledPostCompactHeader}\nenabled = true`, + `${disabledPostCompactHeader}\nenabled = false` + ), + 'utf-8' + ) - const status = service.removeProfile() + expect(new CodexHookService().install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + expect(runtimeHooks.hooks.PreCompact?.[0]?.hooks?.[0]?.command).toBe('pre-compact-user') + expect(runtimeHooks.hooks.PostCompact?.[0]?.hooks?.[0]?.command).toBe('post-compact-disabled') + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain( + `${hookTrustHeader(`${managedHooksPath}:pre_compact:0:0`)}\nenabled = true` + ) + expect(runtimeToml).toContain( + `${hookTrustHeader(`${managedHooksPath}:post_compact:0:0`)}\nenabled = false` + ) + expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:pre_compact:0:0`)) + expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:post_compact:0:0`)) + }) + + it('removes runtime user hook trust after system approval is revoked', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-hook' }] }] } + })}\n`, + 'utf-8' + ) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'user-hook' + } + ]), + 'utf-8' + ) + const service = new CodexHookService() + + expect(service.install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeUserTrustHeader = hookTrustHeader(`${managedHooksPath}:stop:0:0`) + expect(readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')).toContain( + runtimeUserTrustHeader + ) + + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') + expect(service.install().state).toBe('installed') + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).not.toContain(runtimeUserTrustHeader) + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) + }) + + it('refreshes mirrored system user hooks when the system hooks file changes', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-hook-old' }] }] } + })}\n`, + 'utf-8' + ) + + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-hook-new' }] }] } + })}\n`, + 'utf-8' + ) + expect(service.install().state).toBe('installed') + + const managedHooksPath = join(userDataDir, 'codex-runtime-home', 'home', 'hooks.json') + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + const stopCommands = + runtimeHooks.hooks.Stop?.flatMap( + (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] + ) ?? [] + expect(stopCommands).toContain('user-hook-new') + expect(stopCommands).not.toContain('user-hook-old') + }) + + it('refreshes runtime user hooks without installing Orca-managed hooks', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-stop-hook' }] }] } + })}\n`, + 'utf-8' + ) + const disabledStopHeader = hookTrustHeader(`${systemHooksPath}:stop:0:0`) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'user-stop-hook' + } + ]).replace(`${disabledStopHeader}\nenabled = true`, `${disabledStopHeader}\nenabled = false`), + 'utf-8' + ) + + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + + const status = service.refreshRuntimeUserHooks() expect(status.state).toBe('not_installed') - const remaining = readFileSync(profilePath, 'utf-8') - expect(remaining).not.toContain('ORCA AGENT STATUS HOOKS') - expect(remaining).toContain('model = "gpt-5.5"') + expect(status.managedHooksPresent).toBe(false) + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + const runtimeCommands = Object.values(runtimeHooks.hooks).flatMap((definitions) => + definitions.flatMap((definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? []) + ) + expect(runtimeCommands).toEqual(['user-stop-hook']) + expect(runtimeCommands.some((command) => command.includes('codex-hook'))).toBe(false) + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain( + `${hookTrustHeader(`${managedHooksPath}:stop:0:0`)}\nenabled = false` + ) + expect(runtimeToml).not.toContain(':permission_request:0:0') }) - it('does not create legacy global hooks.json when profile migration cleanup has nothing to remove', () => { + it('removes legacy Orca-managed hooks from system ~/.codex during install', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + const legacyScriptPath = join( + tmpHome, + '.orca', + 'agent-hooks', + process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + ) + const legacyCommand = + process.platform === 'win32' ? legacyScriptPath : wrapPosixHookCommand(legacyScriptPath) + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'user-hook' }] }, + { hooks: [{ type: 'command', command: legacyCommand }] } + ], + SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 1, + handlerIndex: 0, + command: legacyCommand + }, + { + sourcePath: systemHooksPath, + eventLabel: 'session_start', + groupIndex: 0, + handlerIndex: 0, + command: legacyCommand + } + ]), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as { + hooks: Record + } + expect(systemHooks.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }]) + expect(systemHooks.hooks.SessionStart).toBeUndefined() + const systemToml = readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8') + expect(systemToml).toContain('model = "system-model"') + expect(systemToml).not.toContain(':stop:1:0') + expect(systemToml).not.toContain(':session_start:0:0') + }) + + it('removes the legacy Orca Codex profile file when it only contains managed hooks', () => { + const systemCodexHome = join(tmpHome, '.codex') + const profilePath = join(systemCodexHome, 'orca-agent-status.config.toml') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + profilePath, + [ + '# BEGIN ORCA AGENT STATUS HOOKS', + '[[hooks.PermissionRequest]]', + '[[hooks.PermissionRequest.hooks]]', + 'type = "command"', + 'command = "codex-hook"', + '# END ORCA AGENT STATUS HOOKS', + '' + ].join('\n'), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + expect(existsSync(profilePath)).toBe(false) + }) + + it('removes only the legacy Orca block from a user-edited Codex profile file', () => { + const systemCodexHome = join(tmpHome, '.codex') + const profilePath = join(systemCodexHome, 'orca-agent-status.config.toml') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + profilePath, + [ + 'model = "gpt-5.5"', + '', + '# BEGIN ORCA AGENT STATUS HOOKS', + '[[hooks.PermissionRequest]]', + '[[hooks.PermissionRequest.hooks]]', + 'type = "command"', + 'command = "codex-hook"', + '# END ORCA AGENT STATUS HOOKS', + '' + ].join('\n'), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + const profileConfig = readFileSync(profilePath, 'utf-8') + expect(profileConfig).toContain('model = "gpt-5.5"') + expect(profileConfig).not.toContain('ORCA AGENT STATUS HOOKS') + expect(profileConfig).not.toContain('codex-hook') + }) + + it('cleans legacy system and profile hooks when runtime hooks.json is malformed during remove', () => { + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + mkdirSync(managedCodexHome, { recursive: true }) + writeFileSync(join(managedCodexHome, 'hooks.json'), '{not json', 'utf-8') + + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + const profilePath = join(systemCodexHome, 'orca-agent-status.config.toml') + const legacyScriptPath = join( + tmpHome, + '.orca', + 'agent-hooks', + process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + ) + const legacyCommand = + process.platform === 'win32' ? legacyScriptPath : wrapPosixHookCommand(legacyScriptPath) + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'user-hook' }] }, + { hooks: [{ type: 'command', command: legacyCommand }] } + ], + SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync( + profilePath, + [ + '# BEGIN ORCA AGENT STATUS HOOKS', + '[[hooks.PermissionRequest]]', + '[[hooks.PermissionRequest.hooks]]', + 'type = "command"', + 'command = "codex-hook"', + '# END ORCA AGENT STATUS HOOKS', + '' + ].join('\n'), + 'utf-8' + ) + + const status = new CodexHookService().remove() + + expect(status.state).toBe('error') + expect(status.detail).toBe('Could not parse Codex hooks.json') + const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as { + hooks: Record + } + expect(systemHooks.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }]) + expect(systemHooks.hooks.SessionStart).toBeUndefined() + expect(existsSync(profilePath)).toBe(false) + }) + + it('cleans duplicate Codex hook representations while keeping status hooks in runtime CODEX_HOME', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + const systemTomlPath = join(systemCodexHome, 'config.toml') + const legacyProfilePath = join(systemCodexHome, 'orca-agent-status.config.toml') + const legacyScriptPath = join( + tmpHome, + '.orca', + 'agent-hooks', + process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + ) + const legacyCommand = + process.platform === 'win32' ? legacyScriptPath : wrapPosixHookCommand(legacyScriptPath) + const userCommand = 'user-stop-hook' + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: userCommand }] }, + { hooks: [{ type: 'command', command: legacyCommand }] } + ], + SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync( + systemTomlPath, + upsertHookTrustEntriesInContent( + ['model = "system-model"', '', '[features]', 'codex_hooks = true', ''].join('\n'), + [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: userCommand + }, + { + sourcePath: systemHooksPath, + eventLabel: 'session_start', + groupIndex: 0, + handlerIndex: 0, + command: legacyCommand + } + ] + ), + 'utf-8' + ) + writeFileSync( + legacyProfilePath, + [ + '# BEGIN ORCA AGENT STATUS HOOKS', + '[[hooks.PermissionRequest]]', + '[[hooks.PermissionRequest.hooks]]', + 'type = "command"', + 'command = "codex-hook"', + '# END ORCA AGENT STATUS HOOKS', + '' + ].join('\n'), + 'utf-8' + ) + const service = new CodexHookService() - service.installProfile() + expect(service.install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + const stopCommands = + runtimeHooks.hooks.Stop?.flatMap( + (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] + ) ?? [] + expect(stopCommands).toContain(userCommand) + expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true) + expect(runtimeHooks.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook') + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain('[features]\nhooks = true') + expect(runtimeToml).not.toContain('codex_hooks') + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:permission_request:0:0`)) + + const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as { + hooks: Record + } + expect(systemHooks.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: userCommand }] }]) + expect(systemHooks.hooks.SessionStart).toBeUndefined() + const systemToml = readFileSync(systemTomlPath, 'utf-8') + expect(systemToml).toContain('codex_hooks = true') + expect(systemToml).not.toContain(':session_start:0:0') + expect(existsSync(legacyProfilePath)).toBe(false) + expect(service.getStatus().state).toBe('installed') + }) + + it('removes managed trust entries when userData resolves through a symlink', () => { + const linkedUserDataDir = join(tmpHome, 'linked-user-data') + symlinkSync(userDataDir, linkedUserDataDir, process.platform === 'win32' ? 'junction' : 'dir') + process.env.ORCA_USER_DATA_PATH = linkedUserDataDir + + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + + const linkedManagedCodexHome = join(linkedUserDataDir, 'codex-runtime-home', 'home') + const linkedHooksPath = join(linkedManagedCodexHome, 'hooks.json') + let runtimeToml = readFileSync(join(linkedManagedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain(hookTrustHeader(`${linkedHooksPath}:permission_request:0:0`)) const status = service.remove() expect(status.state).toBe('not_installed') - expect(existsSync(join(tmpHome, '.codex', 'hooks.json'))).toBe(false) + runtimeToml = readFileSync(join(linkedManagedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).not.toContain(':permission_request:0:0') + expect(runtimeToml).not.toContain(':stop:0:0') + }) + + it('mirrors system Codex config while preserving runtime hook trust on hook install', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + mkdirSync(managedCodexHome, { recursive: true }) + writeFileSync( + join(managedCodexHome, 'config.toml'), + [ + 'model = "runtime-model"', + '', + '[hooks.state."runtime-hook"]', + 'enabled = false', + 'trusted_hash = "sha256:runtime"', + '' + ].join('\n'), + 'utf-8' + ) + + const status = new CodexHookService().install() + + expect(status.state).toBe('installed') + const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(trustConfig).toContain('model = "system-model"') + expect(trustConfig).toContain('[hooks.state."runtime-hook"]') + expect(trustConfig).toContain('enabled = false') + expect(trustConfig).toContain('trusted_hash = "sha256:runtime"') + expect(trustConfig).toContain(':permission_request:0:0') + expect(trustConfig).not.toContain('model = "runtime-model"') + }) + + it('preserves runtime-only project trust while honoring system project untrust', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + join(systemCodexHome, 'config.toml'), + ['model = "system-model"', '', '[projects."/repo"]', 'trust_level = "untrusted"', ''].join( + '\n' + ), + 'utf-8' + ) + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + mkdirSync(managedCodexHome, { recursive: true }) + writeFileSync( + join(managedCodexHome, 'config.toml'), + [ + 'model = "runtime-model"', + '', + '[projects."/repo"]', + 'trust_level = "trusted"', + '', + '[projects."/runtime-only"]', + 'trust_level = "trusted"', + '' + ].join('\n'), + 'utf-8' + ) + + const status = new CodexHookService().install() + + expect(status.state).toBe('installed') + const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(trustConfig).toContain('model = "system-model"') + expect(trustConfig).toContain('[projects."/repo"]\ntrust_level = "untrusted"') + expect(trustConfig).toContain('[projects."/runtime-only"]\ntrust_level = "trusted"') + expect(trustConfig).not.toContain('model = "runtime-model"') }) }) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index f8e18781a8e..87fc8f1f554 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1,19 +1,19 @@ /* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */ import { existsSync, readFileSync, unlinkSync } from 'fs' -import { homedir } from 'os' import { join } from 'path' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' -import { ORCA_CODEX_AGENT_STATUS_PROFILE } from '../../shared/codex-profile' import { createManagedCommandMatcher, buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, + hookDefinitionHasManagedCommand, readHooksJson, removeManagedCommands, wrapPosixHookCommand, writeHooksJson, writeManagedScript, + type HookCommandConfig, type HookDefinition } from '../agent-hooks/installer-utils' import { @@ -26,17 +26,20 @@ import { import { computeTrustKey, computeTrustedHash, + escapeTomlString, + getCodexCanonicalTrustPath, parseTrustKey, readHookTrustEntries, removeHookTrustEntries, upsertHookTrustEntriesInContent, upsertHookTrustEntries, - escapeTomlString, writeConfigAtomically, type CodexEventLabel, type CodexHookTrustState, type CodexTrustEntry } from './config-toml-trust' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' // Why: PreToolUse/PostToolUse give the dashboard a live readout of the // in-flight tool (name + input preview) between UserPromptSubmit and Stop. @@ -53,15 +56,11 @@ const CODEX_EVENTS = [ ] as const function getConfigPath(): string { - return join(homedir(), '.codex', 'hooks.json') + return join(getOrcaManagedCodexHomePath(), 'hooks.json') } function getCodexConfigTomlPath(): string { - return join(homedir(), '.codex', 'config.toml') -} - -function getCodexProfileTomlPath(): string { - return join(homedir(), '.codex', `${ORCA_CODEX_AGENT_STATUS_PROFILE}.config.toml`) + return join(getOrcaManagedCodexHomePath(), 'config.toml') } // Why: Codex's hash key uses the snake_case event label (see @@ -77,8 +76,20 @@ const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> Stop: 'stop' } -const ORCA_PROFILE_BLOCK_START = '# BEGIN ORCA AGENT STATUS HOOKS' -const ORCA_PROFILE_BLOCK_END = '# END ORCA AGENT STATUS HOOKS' +const CODEX_HOOK_EVENT_LABEL: Record = { + ...CODEX_EVENT_LABEL, + PreCompact: 'pre_compact', + PostCompact: 'post_compact' +} + +const LEGACY_ORCA_PROFILE_NAME = 'orca-agent-status' +const LEGACY_ORCA_PROFILE_BLOCK_START = '# BEGIN ORCA AGENT STATUS HOOKS' +const LEGACY_ORCA_PROFILE_BLOCK_END = '# END ORCA AGENT STATUS HOOKS' + +type MirroredRuntimeUserHookTrustEntry = { + entry: CodexTrustEntry + enabled: boolean +} function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' @@ -92,6 +103,451 @@ function getManagedCommand(scriptPath: string): string { return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) } +function getSystemConfigPath(): string { + return join(getSystemCodexHomePath(), 'hooks.json') +} + +function getSystemCodexConfigTomlPath(): string { + return join(getSystemCodexHomePath(), 'config.toml') +} + +function getLegacyCodexProfileTomlPath(): string { + return join(getSystemCodexHomePath(), `${LEGACY_ORCA_PROFILE_NAME}.config.toml`) +} + +function collectManagedTrustEntries( + sourcePath: string, + eventName: string, + definitions: readonly HookDefinition[], + isManagedCommand: (command: string | undefined) => boolean +): CodexTrustEntry[] { + const entries: CodexTrustEntry[] = [] + definitions.forEach((definition, groupIndex) => { + const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] + hooks.forEach((hook, handlerIndex) => { + if (!isManagedCommand(hook.command)) { + return + } + const entry = createHookTrustEntry( + sourcePath, + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + if (entry) { + entries.push(entry) + } + }) + }) + return entries +} + +function createHookTrustEntry( + sourcePath: string, + eventName: string, + groupIndex: number, + handlerIndex: number, + definition: HookDefinition, + hook: HookCommandConfig +): CodexTrustEntry | null { + const eventLabel = CODEX_HOOK_EVENT_LABEL[eventName] + if (!eventLabel || !hook.command) { + return null + } + + return { + sourcePath, + eventLabel, + groupIndex, + handlerIndex, + command: hook.command, + ...(typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}), + ...(typeof hook.async === 'boolean' ? { async: hook.async } : {}), + ...(typeof definition.matcher === 'string' ? { matcher: definition.matcher } : {}), + ...(typeof hook.statusMessage === 'string' ? { statusMessage: hook.statusMessage } : {}) + } +} + +function removeMatchingTrustEntries(configPath: string, entries: readonly CodexTrustEntry[]): void { + if (entries.length === 0) { + return + } + + const existingEntries = readHookTrustEntries(configPath) + const ownedKeys = entries + .map((entry) => { + const key = computeTrustKey(entry) + return existingEntries.get(key)?.trustedHash === computeTrustedHash(entry) ? key : null + }) + .filter((key): key is string => key !== null) + if (ownedKeys.length > 0) { + removeHookTrustEntries(configPath, ownedKeys) + } +} + +function removeStaleRuntimeHookTrustEntries( + tomlPath: string, + runtimeHooksPath: string, + expectedEntries: readonly CodexTrustEntry[] +): void { + const expectedHashes = new Map( + expectedEntries.map((entry) => [computeTrustKey(entry), computeTrustedHash(entry)]) + ) + const canonicalRuntimeHooksPath = getCodexCanonicalTrustPath(runtimeHooksPath) + const staleKeys: string[] = [] + for (const [key, state] of readHookTrustEntries(tomlPath)) { + const parsed = parseTrustKey(key) + if (!parsed || getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalRuntimeHooksPath) { + continue + } + if (expectedHashes.get(key) === state.trustedHash) { + continue + } + staleKeys.push(key) + } + if (staleKeys.length > 0) { + removeHookTrustEntries(tomlPath, staleKeys) + } +} + +function getTrustSignature(entry: CodexTrustEntry): string { + return JSON.stringify({ + eventLabel: entry.eventLabel, + command: entry.command, + timeoutSec: Math.max(1, entry.timeoutSec ?? 600), + async: entry.async ?? false, + matcher: entry.matcher ?? null, + statusMessage: entry.statusMessage ?? null + }) +} + +function getRuntimeHooksWithSystemUserHooks( + runtimeHooks: Record | undefined, + isManagedCommand: (command: string | undefined) => boolean +): { + hooks: Record + trustEntries: MirroredRuntimeUserHookTrustEntry[] +} { + const systemConfigPath = getSystemConfigPath() + const runtimeConfigPath = getConfigPath() + if (systemConfigPath === getConfigPath()) { + return { hooks: { ...runtimeHooks }, trustEntries: [] } + } + + const systemConfig = readHooksJson(systemConfigPath) + if (!systemConfig?.hooks) { + return { hooks: {}, trustEntries: [] } + } + + const nextHooks: Record = {} + const trustedSystemHookSignatures = getTrustedSystemUserHookSignatures( + systemConfigPath, + systemConfig.hooks, + isManagedCommand + ) + for (const [eventName, systemDefinitions] of Object.entries(systemConfig.hooks)) { + if (!Array.isArray(systemDefinitions)) { + continue + } + + const systemUserDefinitions = removeManagedCommands(systemDefinitions, isManagedCommand) + if (systemUserDefinitions.length === 0) { + continue + } + + // Why: runtime hooks are derived from the user's system hooks plus Orca's + // managed hooks. Reusing old runtime user-hook copies would keep deleted or + // edited ~/.codex/hooks.json entries alive for new Orca-launched sessions. + nextHooks[eventName] = dedupeHookDefinitions(systemUserDefinitions) + } + + return { + hooks: nextHooks, + trustEntries: collectMirroredRuntimeUserHookTrustEntries( + runtimeConfigPath, + nextHooks, + trustedSystemHookSignatures, + isManagedCommand + ) + } +} + +function getTrustedSystemUserHookSignatures( + systemConfigPath: string, + systemHooks: Record, + isManagedCommand: (command: string | undefined) => boolean +): Map { + const signatures = new Map() + let trustEntries: Map + try { + trustEntries = readHookTrustEntries(getSystemCodexConfigTomlPath()) + } catch (error) { + // Why: a hand-broken system config.toml should only disable user-hook + // trust mirroring; Orca's managed runtime hooks can still be installed. + console.warn('[codex-hook-service] failed to read system hook trust entries', error) + return signatures + } + for (const [eventName, definitions] of Object.entries(systemHooks)) { + if (!Array.isArray(definitions)) { + continue + } + definitions.forEach((definition, groupIndex) => { + const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] + hooks.forEach((hook, handlerIndex) => { + if (isManagedCommand(hook.command)) { + return + } + const entry = createHookTrustEntry( + systemConfigPath, + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + if (!entry) { + return + } + const state = trustEntries.get(computeTrustKey(entry)) + if (state?.trustedHash === computeTrustedHash(entry)) { + const signature = getTrustSignature(entry) + const enabled = state.enabled !== false + // Why: runtime deduping collapses identical system hook definitions; + // if any duplicate remains enabled, keep the mirrored hook enabled. + if (enabled || !signatures.has(signature)) { + signatures.set(signature, enabled) + } + } + }) + }) + } + return signatures +} + +function collectMirroredRuntimeUserHookTrustEntries( + runtimeConfigPath: string, + runtimeHooks: Record, + trustedSystemHookSignatures: ReadonlyMap, + isManagedCommand: (command: string | undefined) => boolean +): MirroredRuntimeUserHookTrustEntry[] { + if (trustedSystemHookSignatures.size === 0) { + return [] + } + + const entries: MirroredRuntimeUserHookTrustEntry[] = [] + for (const [eventName, definitions] of Object.entries(runtimeHooks)) { + if (!Array.isArray(definitions)) { + continue + } + definitions.forEach((definition, groupIndex) => { + const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] + hooks.forEach((hook, handlerIndex) => { + if (isManagedCommand(hook.command)) { + return + } + const entry = createHookTrustEntry( + runtimeConfigPath, + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + if (!entry) { + return + } + const signature = getTrustSignature(entry) + const enabled = trustedSystemHookSignatures.get(signature) + if (enabled !== undefined) { + entries.push({ entry, enabled }) + } + }) + }) + } + return entries +} + +function escapeRegex(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function applyMirroredRuntimeUserHookTrustStates( + tomlPath: string, + entries: readonly MirroredRuntimeUserHookTrustEntry[] +): void { + if (entries.length === 0 || !existsSync(tomlPath)) { + return + } + + const existing = readFileSync(tomlPath, 'utf-8') + let updated = existing + for (const { entry, enabled } of entries) { + const escapedKey = escapeRegex(escapeTomlString(computeTrustKey(entry))) + const pattern = new RegExp( + `(\\[hooks\\.state\\."${escapedKey}"\\]\\r?\\n[ \\t]*enabled[ \\t]*=[ \\t]*)(true|false)` + ) + updated = updated.replace(pattern, `$1${enabled}`) + } + if (updated !== existing) { + writeConfigAtomically(tomlPath, updated) + } +} + +function dedupeHookDefinitions(definitions: readonly HookDefinition[]): HookDefinition[] { + const seen = new Set() + return definitions.filter((definition) => { + const key = JSON.stringify(definition) + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + +function cleanupLegacySystemManagedHooks(): void { + const legacyConfigPath = getSystemConfigPath() + const runtimeConfigPath = getConfigPath() + if (legacyConfigPath === runtimeConfigPath) { + return + } + + const config = readHooksJson(legacyConfigPath) + if (!config?.hooks) { + return + } + + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const nextHooks = { ...config.hooks } + const trustEntries: CodexTrustEntry[] = [] + let removedManagedHook = false + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const eventTrustEntries = collectManagedTrustEntries( + legacyConfigPath, + eventName, + definitions, + isManagedCommand + ) + trustEntries.push(...eventTrustEntries) + const cleaned = removeManagedCommands(definitions, isManagedCommand) + removedManagedHook ||= definitions.some((definition) => + hookDefinitionHasManagedCommand(definition, isManagedCommand) + ) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + // Why: Codex hooks moved to Orca's managed CODEX_HOME; old entries in + // ~/.codex would keep external Codex sessions reporting into Orca. + if (removedManagedHook) { + writeHooksJson(legacyConfigPath, { ...config, hooks: nextHooks }) + } + removeMatchingTrustEntries(getSystemCodexConfigTomlPath(), trustEntries) +} + +function stripLegacyManagedProfileBlock(content: string): string { + const start = content.indexOf(LEGACY_ORCA_PROFILE_BLOCK_START) + if (start === -1) { + return content + } + const endMarker = content.indexOf(LEGACY_ORCA_PROFILE_BLOCK_END, start) + const end = endMarker === -1 ? content.length : endMarker + LEGACY_ORCA_PROFILE_BLOCK_END.length + const before = content.slice(0, start).replace(/[ \t]*(?:\r?\n)*$/, '') + const after = content.slice(end).replace(/^(?:\r?\n)+/, '') + if (!before) { + return after + } + if (!after) { + return before.endsWith('\n') ? before : `${before}\n` + } + return `${before}\n\n${after}` +} + +function cleanupLegacyCodexProfileHooks(): void { + const profilePath = getLegacyCodexProfileTomlPath() + if (!existsSync(profilePath)) { + return + } + + const existing = readFileSync(profilePath, 'utf-8') + const next = stripLegacyManagedProfileBlock(existing) + if (next === existing) { + return + } + // Why: #2778 wrote Orca hooks into a Codex profile file. Runtime CODEX_HOME + // supersedes that representation, so remove only Orca's marked block. + if (next.trim().length === 0) { + unlinkSync(profilePath) + } else { + writeConfigAtomically(profilePath, next) + } +} + +function cleanupLegacyManagedHookRepresentations(): void { + try { + cleanupLegacySystemManagedHooks() + cleanupLegacyCodexProfileHooks() + } catch (error) { + console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error) + } +} + +function removeRuntimeManagedHookTrustEntries(configPath: string): void { + try { + const tomlPath = getCodexConfigTomlPath() + const existingEntries = readHookTrustEntries(tomlPath) + const scriptPath = getManagedScriptPath() + const command = getManagedCommand(scriptPath) + const managedEventLabels = new Set( + CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) + ) + // Why: only drop entries WE wrote. The same config.toml can contain + // user-approved trust entries for non-Orca commands, so match by hash + // equivalence to our managed command — a sourcePath-only filter would + // wipe the user's manually-approved entries. + const ourKeys: string[] = [] + const canonicalConfigPath = getCodexCanonicalTrustPath(configPath) + for (const [key, state] of existingEntries) { + const parts = parseTrustKey(key) + if (parts === null) { + continue + } + if (getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalConfigPath) { + continue + } + if (!managedEventLabels.has(parts.eventLabel)) { + continue + } + const expectedHash = computeTrustedHash({ + sourcePath: configPath, + eventLabel: parts.eventLabel, + groupIndex: parts.groupIndex, + handlerIndex: parts.handlerIndex, + command + }) + if (state.trustedHash !== expectedHash) { + continue + } + ourKeys.push(key) + } + if (ourKeys.length > 0) { + removeHookTrustEntries(tomlPath, ourKeys) + } + } catch (error) { + // Best effort — stale trust entries are harmless once hooks.json no + // longer references the hook. Log so a programmer error doesn't disappear silently. + console.warn('[codex-hook-service] failed to clean trust entries', error) + } +} + function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ @@ -143,198 +599,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ].join('\n') } -function findManagedProfileBlock(content: string): string | null { - const start = content.indexOf(ORCA_PROFILE_BLOCK_START) - if (start === -1) { - return null - } - const endMarker = content.indexOf(ORCA_PROFILE_BLOCK_END, start) - const end = endMarker === -1 ? content.length : endMarker + ORCA_PROFILE_BLOCK_END.length - return content.slice(start, end) -} - -function stripManagedProfileBlock(content: string): string { - const start = content.indexOf(ORCA_PROFILE_BLOCK_START) - if (start === -1) { - return content - } - const endMarker = content.indexOf(ORCA_PROFILE_BLOCK_END, start) - const end = endMarker === -1 ? content.length : endMarker + ORCA_PROFILE_BLOCK_END.length - const before = content.slice(0, start).replace(/[ \t]*(?:\r?\n)*$/, '') - const after = content.slice(end).replace(/^(?:\r?\n)+/, '') - if (!before) { - return after - } - if (!after) { - return before.endsWith('\n') ? before : `${before}\n` - } - return `${before}\n\n${after}` -} - -function appendManagedProfileBlock(existing: string, block: string): string { - const trimmedExisting = stripManagedProfileBlock(existing).replace(/[ \t]*(?:\r?\n)*$/, '') - if (!trimmedExisting) { - return `${block}\n` - } - return `${trimmedExisting}\n\n${block}\n` -} - -function buildProfileTrustEntry( - profilePath: string, - eventName: (typeof CODEX_EVENTS)[number], - command: string -): CodexTrustEntry { - return { - sourcePath: profilePath, - eventLabel: CODEX_EVENT_LABEL[eventName], - groupIndex: 0, - handlerIndex: 0, - command - } -} - -function buildManagedProfileBlock(profilePath: string, command: string): string { - const lines = [ - ORCA_PROFILE_BLOCK_START, - '# Managed by Orca so only Codex sessions launched from Orca load agent-status hooks.' - ] - for (const eventName of CODEX_EVENTS) { - const entry = buildProfileTrustEntry(profilePath, eventName, command) - lines.push( - `[hooks.state."${escapeTomlString(computeTrustKey(entry))}"]`, - 'enabled = true', - `trusted_hash = "${escapeTomlString(computeTrustedHash(entry))}"`, - '', - `[[hooks.${eventName}]]`, - `[[hooks.${eventName}.hooks]]`, - 'type = "command"', - `command = "${escapeTomlString(command)}"`, - '' - ) - } - lines.push(ORCA_PROFILE_BLOCK_END) - return lines.join('\n') -} - export class CodexHookService { - getProfileStatus(): AgentHookInstallStatus { - const configPath = getCodexProfileTomlPath() - if (!existsSync(configPath)) { - return { - agent: 'codex', - state: 'not_installed', - configPath, - managedHooksPresent: false, - detail: null - } - } - - const scriptPath = getManagedScriptPath() - const command = getManagedCommand(scriptPath) - const content = readFileSync(configPath, 'utf-8') - const block = findManagedProfileBlock(content) - if (!block) { - return { - agent: 'codex', - state: 'not_installed', - configPath, - managedHooksPresent: false, - detail: null - } - } - - const escapedCommandLine = `command = "${escapeTomlString(command)}"` - const missing = CODEX_EVENTS.filter( - (eventName) => - !block.includes(`[[hooks.${eventName}]]`) || !block.includes(escapedCommandLine) - ) - - let trustEntries: Map - let trustReadError: string | null = null - try { - trustEntries = readHookTrustEntries(configPath) - } catch (error) { - trustEntries = new Map() - trustReadError = error instanceof Error ? error.message : String(error) - } - - const trustMissing: string[] = [] - const disabled: string[] = [] - for (const eventName of CODEX_EVENTS) { - const entry = buildProfileTrustEntry(configPath, eventName, command) - const actualState = trustEntries.get(computeTrustKey(entry)) - if (actualState?.trustedHash !== computeTrustedHash(entry)) { - trustMissing.push(eventName) - } else if (actualState.enabled === false) { - disabled.push(eventName) - } - } - - if ( - missing.length === 0 && - trustMissing.length === 0 && - disabled.length === 0 && - !trustReadError - ) { - return { - agent: 'codex', - state: 'installed', - configPath, - managedHooksPresent: true, - detail: null - } - } - - const parts: string[] = [] - if (missing.length > 0) { - parts.push(`Profile hook missing for events: ${missing.join(', ')}`) - } - if (trustReadError) { - parts.push(`Trust entries unverifiable: ${trustReadError}`) - } else if (trustMissing.length > 0) { - parts.push(`Trust entry missing or stale for events: ${trustMissing.join(', ')}`) - } - if (disabled.length > 0) { - parts.push(`Managed hook disabled for events: ${disabled.join(', ')}`) - } - return { - agent: 'codex', - state: 'partial', - configPath, - managedHooksPresent: true, - detail: parts.join('; ') - } - } - - installProfile(): AgentHookInstallStatus { - const configPath = getCodexProfileTomlPath() - const scriptPath = getManagedScriptPath() - const command = getManagedCommand(scriptPath) - const existing = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '' - const next = appendManagedProfileBlock(existing, buildManagedProfileBlock(configPath, command)) - writeManagedScript(scriptPath, getManagedScript()) - writeConfigAtomically(configPath, next) - return this.getProfileStatus() - } - - removeProfile(): AgentHookInstallStatus { - const configPath = getCodexProfileTomlPath() - if (!existsSync(configPath)) { - return this.getProfileStatus() - } - const existing = readFileSync(configPath, 'utf-8') - const next = stripManagedProfileBlock(existing) - if (next === existing) { - return this.getProfileStatus() - } - if (next.trim().length === 0) { - unlinkSync(configPath) - } else { - writeConfigAtomically(configPath, next) - } - return this.getProfileStatus() - } - getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() @@ -463,15 +728,15 @@ export class CodexHookService { } } - const command = getManagedCommand(scriptPath) - const nextHooks = { ...config.hooks } - const managedEvents = new Set(CODEX_EVENTS) - // Why: match by script filename (not exact command string) so a fresh // install sweeps stale entries left by older builds or a different // Electron userData path (dev vs. prod). Without this, repeated installs // accumulate duplicate hook entries pointing at defunct scripts. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const command = getManagedCommand(scriptPath) + const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand) + const nextHooks = hookPlan.hooks + const managedEvents = new Set(CODEX_EVENTS) // Why: sweep managed entries out of events we no longer subscribe to // (e.g., PreToolUse from a prior install). Without this, users who @@ -500,7 +765,8 @@ export class CodexHookService { // hook sits in the "review required" pile. We compute the trust hash for // each managed entry as we install it and persist it alongside hooks.json // so the user does not have to /hooks-approve after every install. - const trustEntries: CodexTrustEntry[] = [] + const mirroredUserTrustEntries = hookPlan.trustEntries + const trustEntries: CodexTrustEntry[] = mirroredUserTrustEntries.map(({ entry }) => entry) for (const eventName of CODEX_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) @@ -528,7 +794,14 @@ 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 { - upsertHookTrustEntries(getCodexConfigTomlPath(), trustEntries) + const tomlPath = getCodexConfigTomlPath() + syncSystemConfigIntoManagedCodexHome() + // Why: system user hook approvals are mirrored into runtime CODEX_HOME. + // If the user later revokes approval in ~/.codex/config.toml, preserving + // all old runtime [hooks.state.*] blocks would keep Orca Codex trusted. + removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) + upsertHookTrustEntries(tomlPath, trustEntries) + applyMirroredRuntimeUserHookTrustStates(tomlPath, mirroredUserTrustEntries) } catch (error) { return { agent: 'codex', @@ -538,6 +811,12 @@ 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.` } } + try { + cleanupLegacySystemManagedHooks() + cleanupLegacyCodexProfileHooks() + } catch (error) { + console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error) + } return this.getStatus() } @@ -634,74 +913,48 @@ export class CodexHookService { } } - async installRemoteProfile( - sftp: SFTPWrapper, - remoteHome: string - ): Promise { - const normalizedHome = remoteHome.replace(/\/$/, '') - const remoteProfilePath = `${normalizedHome}/.codex/${ORCA_CODEX_AGENT_STATUS_PROFILE}.config.toml` - const remoteScriptPath = `${normalizedHome}/.orca/agent-hooks/codex-hook.sh` - const remoteGlobalConfigPath = `${normalizedHome}/.codex/hooks.json` - try { - const command = wrapPosixHookCommand(remoteScriptPath) - const existingProfile = (await readTextFileRemote(sftp, remoteProfilePath)) ?? '' - const nextProfile = appendManagedProfileBlock( - existingProfile, - buildManagedProfileBlock(remoteProfilePath, command) - ) - - await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) - if (nextProfile !== existingProfile) { - await writeTextFileRemoteAtomic(sftp, remoteProfilePath, nextProfile) - } - - // Why: profile-scoped Codex hooks keep Orca out of external remote - // `codex` sessions. Sweep legacy global Orca entries left by older - // remote installers so the profile is the only active Codex hook source. - const existingGlobalConfig = await readTextFileRemote(sftp, remoteGlobalConfigPath) - if (existingGlobalConfig !== null) { - const globalConfig = await readHooksJsonRemote(sftp, remoteGlobalConfigPath) - if (globalConfig) { - let removedGlobalHooks = false - const nextHooks = { ...globalConfig.hooks } - const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') - for (const [eventName, definitions] of Object.entries(nextHooks)) { - if (!Array.isArray(definitions)) { - continue - } - const cleaned = removeManagedCommands(definitions, isManagedCommand) - if (JSON.stringify(cleaned) !== JSON.stringify(definitions)) { - removedGlobalHooks = true - } - if (cleaned.length === 0) { - delete nextHooks[eventName] - } else { - nextHooks[eventName] = cleaned - } - } - if (removedGlobalHooks) { - globalConfig.hooks = nextHooks - await writeHooksJsonRemote(sftp, remoteGlobalConfigPath, globalConfig) - } - } - } - - return { - agent: 'codex', - state: 'installed', - configPath: remoteProfilePath, - managedHooksPresent: true, - detail: null - } - } catch (err) { + refreshRuntimeUserHooks(): AgentHookInstallStatus { + const configPath = getConfigPath() + const config = readHooksJson(configPath) + if (!config) { + // Why: disabled launch prep used to call remove(); preserve its legacy + // cleanup behavior even when runtime hooks.json is malformed. + cleanupLegacyManagedHookRepresentations() return { agent: 'codex', state: 'error', - configPath: remoteProfilePath, + configPath, managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) + detail: 'Could not parse Codex hooks.json' } } + + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand) + config.hooks = hookPlan.hooks + writeHooksJson(configPath, config) + + try { + const tomlPath = getCodexConfigTomlPath() + const trustEntries = hookPlan.trustEntries.map(({ entry }) => entry) + syncSystemConfigIntoManagedCodexHome() + // Why: this path is used when Orca status hooks are disabled. The + // runtime CODEX_HOME should keep user hooks, but not Orca-managed trust. + removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) + upsertHookTrustEntries(tomlPath, trustEntries) + applyMirroredRuntimeUserHookTrustStates(tomlPath, hookPlan.trustEntries) + } catch (error) { + return { + agent: 'codex', + state: 'error', + configPath, + managedHooksPresent: false, + detail: `User hooks refreshed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` + } + } + + cleanupLegacyManagedHookRepresentations() + return this.getStatus() } remove(): AgentHookInstallStatus { @@ -709,6 +962,9 @@ export class CodexHookService { const configExists = existsSync(configPath) const config = readHooksJson(configPath) if (!config) { + // Why: a malformed runtime hooks.json should not strand old hooks in + // ~/.codex or the legacy profile after the user disables Codex hooks. + cleanupLegacyManagedHookRepresentations() return { agent: 'codex', state: 'error', @@ -748,51 +1004,9 @@ export class CodexHookService { // Why: also drop our trust entries so config.toml doesn't accumulate dead // [hooks.state."..."] blocks across install/remove cycles. Best-effort — // a stale entry is harmless once hooks.json no longer references it. - try { - const tomlPath = getCodexConfigTomlPath() - const existingEntries = readHookTrustEntries(tomlPath) - const scriptPath = getManagedScriptPath() - const command = getManagedCommand(scriptPath) - const managedEventLabels = new Set( - CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) - ) - // Why: only drop entries WE wrote. configPath (~/.codex/hooks.json) is - // shared with Codex CLI, so user-approved trust entries for non-Orca - // commands live in the same `[hooks.state.*]` namespace. Match by hash - // equivalence to our managed command — a sourcePath-only filter would - // wipe the user's manually-approved entries. - const ourKeys: string[] = [] - for (const [key, state] of existingEntries) { - const parts = parseTrustKey(key) - if (parts === null) { - continue - } - if (parts.sourcePath !== configPath) { - continue - } - if (!managedEventLabels.has(parts.eventLabel)) { - continue - } - const expectedHash = computeTrustedHash({ - sourcePath: configPath, - eventLabel: parts.eventLabel, - groupIndex: parts.groupIndex, - handlerIndex: parts.handlerIndex, - command - }) - if (state.trustedHash !== expectedHash) { - continue - } - ourKeys.push(key) - } - if (ourKeys.length > 0) { - removeHookTrustEntries(tomlPath, ourKeys) - } - } catch (error) { - // Best effort — stale trust entries are harmless once hooks.json no - // longer references the hook. Log so a programmer error doesn't disappear silently. - console.warn('[codex-hook-service] failed to clean trust entries', error) - } + removeRuntimeManagedHookTrustEntries(configPath) + + cleanupLegacyManagedHookRepresentations() return this.getStatus() } diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index f5c6cedb617..b4b5b9149e0 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -117,6 +117,7 @@ export class DaemonPtyAdapter implements IPtyProvider { rows: effectiveRows, cwd: effectiveCwd, env: opts.env, + envToDelete: opts.envToDelete, command: opts.command, // Why: without this, the daemon always spawns cmd.exe (COMSPEC) or // PowerShell as a fallback — regardless of which shell the renderer diff --git a/src/main/daemon/daemon-pty-provider.ts b/src/main/daemon/daemon-pty-provider.ts index 74a5d834874..fa8252488da 100644 --- a/src/main/daemon/daemon-pty-provider.ts +++ b/src/main/daemon/daemon-pty-provider.ts @@ -12,6 +12,7 @@ export type DaemonSpawnOptions = { sessionId: string cwd?: string env?: Record + envToDelete?: string[] command?: string } @@ -44,6 +45,7 @@ export class DaemonPtyProvider { rows: opts.rows, cwd: opts.cwd, env: opts.env, + envToDelete: opts.envToDelete, command: opts.command }) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index c6e29f568a0..222711fa065 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -212,6 +212,7 @@ export class DaemonServer { rows: p.rows, cwd: p.cwd, env: p.env, + envToDelete: p.envToDelete, command: p.command, shellOverride: p.shellOverride, terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index b18e60ac192..9d3ea450993 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -38,7 +38,8 @@ import { createPtySubprocess } from './pty-subprocess' const ORCA_SHELL_WRAPPER_ENV = [ 'ORCA_ATTRIBUTION_SHIM_DIR', 'ORCA_OPENCODE_CONFIG_DIR', - 'ORCA_PI_CODING_AGENT_DIR' + 'ORCA_PI_CODING_AGENT_DIR', + 'ORCA_CODEX_HOME' ] as const const POWERSHELL_OSC133_COMMAND_ARGS = ['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)] const ZSH_SHELL_READY_DIR = /shell-ready[\\/]zsh/ @@ -485,6 +486,61 @@ describe('createPtySubprocess', () => { expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') }) + it('uses shell wrapper when Codex home must survive shell startup', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { + SHELL: '/bin/zsh', + CODEX_HOME: '/tmp/orca-codex-home', + ORCA_CODEX_HOME: '/tmp/orca-codex-home' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const lastCall = spawnMock.mock.calls.at(-1)! + expect(lastCall[1]).toEqual(['-l']) + expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR) + expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + }) + + it('deletes requested env keys after merging daemon process env', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const previousCodexHome = process.env.CODEX_HOME + process.env.CODEX_HOME = '/host/codex-home' + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { SHELL: '/bin/bash' }, + envToDelete: ['CODEX_HOME'] + }) + } finally { + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = previousCodexHome + } + } + + const lastCall = spawnMock.mock.calls.at(-1)! + expect(lastCall[2].env.CODEX_HOME).toBeUndefined() + }) + it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 84e52f0bfe4..e1bb01039cc 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -32,6 +32,7 @@ export type PtySubprocessOptions = { rows: number cwd?: string env?: Record + envToDelete?: string[] command?: string /** Explicit shell executable path/basename the renderer asked for. * Overrides env.COMSPEC / env.SHELL resolution inside the daemon so a user @@ -184,6 +185,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // restores clickable refs like `owner/repo#123` / `PR#123`. FORCE_HYPERLINK: '1' } as Record + for (const key of opts.envToDelete ?? []) { + delete env[key] + } // Why: the daemon is forked from Electron and can inherit the pane identity // of the terminal that launched `pn dev`; each PTY must opt into its own. removeUnspecifiedPaneIdentityEnv(env, opts.env) @@ -260,7 +264,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl ? getShellReadyLaunchConfig(shellPath) : env.ORCA_ATTRIBUTION_SHIM_DIR || env.ORCA_OPENCODE_CONFIG_DIR || - env.ORCA_PI_CODING_AGENT_DIR + env.ORCA_PI_CODING_AGENT_DIR || + env.ORCA_CODEX_HOME ? getAttributionShellLaunchConfig(shellPath) : null if (shellLaunch) { diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index afdba671697..25f1cea38a5 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -228,12 +228,17 @@ describePosix('daemon shell-ready launch config', () => { '[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' const piRestoreLine = '[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"' + const codexRestoreLine = + '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' expect(zshrc).toContain(restoreLine) expect(zlogin).toContain(restoreLine) expect(bashRc).toContain(restoreLine) expect(zshrc).toContain(piRestoreLine) expect(zlogin).toContain(piRestoreLine) expect(bashRc).toContain(piRestoreLine) + expect(zshrc).toContain(codexRestoreLine) + expect(zlogin).toContain(codexRestoreLine) + expect(bashRc).toContain(codexRestoreLine) }) // Why: regression guard for issue #2422. The daemon-side bash wrapper must diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 2955046fda4..a5e265a781b 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -101,6 +101,8 @@ __orca_restore_attribution_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" # Why: PI_CODING_AGENT_DIR is also a single-root env var users may re-export. [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. +[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command exits — mirrors the zsh daemon wrapper. # Without this, bash users (default on most Linux distros) keep a stuck @@ -203,6 +205,7 @@ if [[ ! -o login ]]; then [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" # Why: PI_CODING_AGENT_DIR must keep the same PTY-scoped overlay after rc files. [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" + [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" fi __orca_osc133_precmd() { local exit_code=$? @@ -298,6 +301,7 @@ __orca_restore_attribution_path # Why: .zlogin is the final login startup file before the prompt is shown. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then __orca_prompt_mark() { printf "${SHELL_READY_MARKER}" diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index bfa3c058c00..672eddd4805 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -12,6 +12,7 @@ export type CreateOrAttachOptions = { rows: number cwd?: string env?: Record + envToDelete?: string[] command?: string /** Explicit shell the renderer asked for (e.g. 'wsl.exe' for "New WSL * terminal" from the "+" menu). Forwarded to the subprocess spawner so the @@ -38,6 +39,7 @@ export type TerminalHostOptions = { rows: number cwd?: string env?: Record + envToDelete?: string[] command?: string shellOverride?: string terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' @@ -101,6 +103,7 @@ export class TerminalHost { rows: size.rows, cwd: opts.cwd, env: opts.env, + envToDelete: opts.envToDelete, command: opts.command, shellOverride: opts.shellOverride, terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index a88fb46c659..ef3519a4d01 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,10 +1,10 @@ // ─── Protocol Version ──────────────────────────────────────────────── // Why: daemons can survive app updates. Bump for IPC wire-shape changes, or // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. -// Why: bumped from 6 -> 7 so existing daemons restart with the headless -// emulator's mouse-mode snapshot tracking for mobile alternate-screen TUIs. -export const PROTOCOL_VERSION = 7 -export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6] as const +// Why: bumped from 7 -> 8 so existing daemons restart with envToDelete support; +// older daemons re-merge process.env and can leak host CODEX_HOME into WSL PTYs. +export const PROTOCOL_VERSION = 8 +export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7] as const // ─── Session State Machine ────────────────────────────────────────── export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' @@ -63,6 +63,7 @@ export type CreateOrAttachRequest = { rows: number cwd?: string env?: Record + envToDelete?: string[] command?: string /** Explicit Windows shell override selected by the user (e.g. 'wsl.exe'). * The daemon forwards this to its subprocess spawner so each tab honors diff --git a/src/main/index.ts b/src/main/index.ts index daa9c0edcc4..028f7bb73a4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -49,7 +49,8 @@ import { installDevParentDisconnectQuit, installDevParentWatchdog, installUncaughtPipeErrorGuard, - patchPackagedProcessPath + patchPackagedProcessPath, + shouldInstallManagedHooks } from './startup/configure-process' import { startFirstWindowStartupServices } from './startup/first-window-startup-services' import { getDevInstanceIdentity } from './startup/dev-instance-identity' @@ -60,6 +61,7 @@ import { attachMainWindowServices } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' import { CodexAccountService } from './codex-accounts/service' import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' +import { codexHookService } from './codex/hook-service' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import { StarNagService } from './star-nag/service' @@ -149,6 +151,9 @@ if (app.isPackaged && process.platform !== 'win32') { }) } configureDevUserDataPath(is.dev) +// Why: CLI-shared Codex helpers cannot import Electron. Seed the resolved +// app userData path once Electron has applied dev/e2e overrides. +process.env.ORCA_USER_DATA_PATH ??= app.getPath('userData') function focusExistingWindow(): void { // Why: the second-instance event fires on the *primary* Electron process @@ -256,6 +261,36 @@ if (hasSingleInstanceLock) { enableMainProcessGpuFeatures() } +function prepareCodexRuntimeHomeForLaunch(): string { + const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch() + const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) + try { + // Why: launch prep is reachable after startup via PTY/runtime paths; honor + // the persisted off switch so those launches cannot reinstall removed hooks. + const status = hooksEnabled + ? codexHookService.install() + : codexHookService.refreshRuntimeUserHooks() + if (status.state === 'error') { + console.warn( + `[codex-hook-service] failed to ${ + hooksEnabled ? 'refresh' : 'refresh user' + } runtime hooks before launch`, + status.detail + ) + } + } catch (error) { + // Why: hook install/removal is best-effort launch prep. A malformed hooks file + // should not block the Codex process from starting with its prepared auth. + console.warn( + `[codex-hook-service] failed to ${ + hooksEnabled ? 'refresh' : 'refresh user' + } runtime hooks before launch`, + error + ) + } + return runtimeHomePath +} + function openMainWindow(): BrowserWindow { if (!store) { throw new Error('Store must be initialized before opening the main window') @@ -387,10 +422,7 @@ function openMainWindow(): BrowserWindow { rendererWebContentsId, automations, { - prepareForCodexLaunch: () => - store!.getSettings().activeCodexManagedAccountId - ? codexRuntimeHome!.prepareForCodexLaunch() - : null, + prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch() }, agentAwakeService ?? undefined, @@ -408,10 +440,7 @@ function openMainWindow(): BrowserWindow { window, store, runtime, - () => - store!.getSettings().activeCodexManagedAccountId - ? codexRuntimeHome!.prepareForCodexLaunch() - : null, + prepareCodexRuntimeHomeForLaunch, () => claudeRuntimeAuth!.prepareForClaudeLaunch(), { onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { @@ -996,10 +1025,10 @@ app.whenReady().then(async () => { runtimeService.setAutomationService(automations) runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) runtimeService.setCommitMessageAgentEnvironmentResolvers({ - prepareForCodexLaunch: () => - store!.getSettings().activeCodexManagedAccountId - ? codexRuntimeHome!.prepareForCodexLaunch() - : null, + // Why: local Codex hooks and auth now live in Orca's managed runtime home + // even for the system-default path, so every Orca-launched Codex process + // must resolve CODEX_HOME through the runtime-home service. + prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch() }) starNag = new StarNagService(store, stats) @@ -1011,12 +1040,14 @@ app.whenReady().then(async () => { }) ) nativeTheme.themeSource = store.getSettings().theme ?? 'system' - // Why: the persisted off switch must run before any auto-install path so - // users who removed Orca-managed hooks do not see them silently reappear on launch. - if (isAgentStatusHooksEnabled(store.getSettings())) { - runManagedHookInstallers(MANAGED_AGENT_HOOK_INSTALLERS) - } else { - removeManagedAgentHooks() + if (shouldInstallManagedHooks(is.dev)) { + // Why: the persisted off switch must run before any auto-install path so + // users who removed Orca-managed hooks do not see them silently reappear on launch. + if (isAgentStatusHooksEnabled(store.getSettings())) { + runManagedHookInstallers(MANAGED_AGENT_HOOK_INSTALLERS) + } else { + removeManagedAgentHooks() + } } app.on('child-process-gone', (_event, details) => { @@ -1164,7 +1195,7 @@ app.whenReady().then(async () => { if (serveOptions) { registerHeadlessPtyRuntime( runtime, - () => codexRuntimeHome!.prepareForCodexLaunch(), + prepareCodexRuntimeHomeForLaunch, () => store!.getSettings(), () => claudeRuntimeAuth!.prepareForClaudeLaunch(), store diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 235d726e7f0..79246f38b53 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -99,7 +99,7 @@ export function registerAgentHookHandlers(): void { }) ipcMain.handle('agentHooks:codexStatus', (): AgentHookInstallStatus => { try { - return codexHookService.getProfileStatus() + return codexHookService.getStatus() } catch (err) { return { agent: 'codex', diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 3615d8d43af..d42fd905fdc 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -907,9 +907,7 @@ describe('registerFilesystemHandlers', () => { ) }) - it('preserves the inherited Codex environment when no managed account is selected', async () => { - const previousCodexHome = process.env.CODEX_HOME - process.env.CODEX_HOME = '/system/codex-home' + it('prepares the Orca-managed Codex home for the default system selection', async () => { const context = { branch: 'feature/ai', stagedSummary: 'M\tREADME.md', @@ -923,26 +921,23 @@ describe('registerFilesystemHandlers', () => { message: 'Update README' }) - try { - registerFilesystemHandlers(store as never, { - prepareForCodexLaunch: () => null - }) + registerFilesystemHandlers(store as never, { + prepareForCodexLaunch: () => '/orca-managed/codex-home' + }) - await handlers.get('git:generateCommitMessage')!(null, { - worktreePath: WORKTREE_FEATURE_PATH - }) + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH + }) - expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(context, params, { + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( + context, + params, + expect.objectContaining({ kind: 'local', - cwd: WORKTREE_FEATURE_PATH + cwd: WORKTREE_FEATURE_PATH, + env: expect.objectContaining({ CODEX_HOME: '/orca-managed/codex-home' }) }) - } finally { - if (previousCodexHome === undefined) { - delete process.env.CODEX_HOME - } else { - process.env.CODEX_HOME = previousCodexHome - } - } + ) }) it('returns a sanitized error when local agent account preparation fails', async () => { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index e672e035521..c6222f0fbaf 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -195,6 +195,7 @@ describe('registerPtyHandlers', () => { const savedPiAgentDir = process.env.PI_CODING_AGENT_DIR const savedOrcaPiAgentDir = process.env.ORCA_PI_CODING_AGENT_DIR const savedOrcaPiSourceAgentDir = process.env.ORCA_PI_SOURCE_AGENT_DIR + const savedOrcaCodexHome = process.env.ORCA_CODEX_HOME const savedOrcaClaudeSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS beforeEach(() => { @@ -206,6 +207,7 @@ describe('registerPtyHandlers', () => { delete process.env.PI_CODING_AGENT_DIR delete process.env.ORCA_PI_SOURCE_AGENT_DIR delete process.env.ORCA_PI_CODING_AGENT_DIR + delete process.env.ORCA_CODEX_HOME handlers.clear() handleMock.mockReset() onMock.mockReset() @@ -312,6 +314,11 @@ describe('registerPtyHandlers', () => { } else { process.env.ORCA_PI_SOURCE_AGENT_DIR = savedOrcaPiSourceAgentDir } + if (savedOrcaCodexHome === undefined) { + delete process.env.ORCA_CODEX_HOME + } else { + process.env.ORCA_CODEX_HOME = savedOrcaCodexHome + } if (savedOrcaClaudeSettings === undefined) { delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS } else { @@ -489,6 +496,7 @@ describe('registerPtyHandlers', () => { it('injects the selected Codex home into Orca terminal PTYs', async () => { const env = await spawnAndGetEnv(undefined, undefined, () => '/tmp/orca-codex-home') expect(env.CODEX_HOME).toBe('/tmp/orca-codex-home') + expect(env.ORCA_CODEX_HOME).toBe('/tmp/orca-codex-home') }) it('injects the OpenCode hook env into Orca terminal PTYs', async () => { @@ -745,13 +753,14 @@ describe('registerPtyHandlers', () => { expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix') }) - it('leaves ambient CODEX_HOME untouched when system default is selected', async () => { + it('overrides ambient CODEX_HOME with the Orca-managed home for system default', async () => { const env = await spawnAndGetEnv( undefined, { CODEX_HOME: '/tmp/system-codex-home' }, - () => null + () => '/tmp/orca-codex-home' ) - expect(env.CODEX_HOME).toBe('/tmp/system-codex-home') + expect(env.CODEX_HOME).toBe('/tmp/orca-codex-home') + expect(env.ORCA_CODEX_HOME).toBe('/tmp/orca-codex-home') }) describe('daemon-active provider (parity with LocalPtyProvider)', () => { @@ -783,12 +792,18 @@ describe('registerPtyHandlers', () => { return daemonSpawn } - async function daemonSpawnAndGetEnv( + type DaemonSpawnCall = { + env: Record + envToDelete?: string[] + } + + async function daemonSpawnAndGetOptions( argsEnv?: Record, getSelectedCodexHomePath?: () => string | null, getSettings?: () => { enableGitHubAttribution: boolean }, - processEnvOverrides?: Record - ): Promise> { + processEnvOverrides?: Record, + spawnArgs?: { cwd?: string; shellOverride?: string } + ): Promise { const daemonSpawn = setupDaemonAdapter() const savedEnv: Record = {} if (processEnvOverrides) { @@ -812,9 +827,10 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, + ...spawnArgs, ...(argsEnv ? { env: argsEnv } : {}) }) - return daemonSpawn.mock.calls.at(-1)![0].env + return daemonSpawn.mock.calls.at(-1)![0] as DaemonSpawnCall } finally { for (const [k, v] of Object.entries(savedEnv)) { if (v === undefined) { @@ -826,6 +842,24 @@ describe('registerPtyHandlers', () => { } } + async function daemonSpawnAndGetEnv( + argsEnv?: Record, + getSelectedCodexHomePath?: () => string | null, + getSettings?: () => { enableGitHubAttribution: boolean }, + processEnvOverrides?: Record, + spawnArgs?: { cwd?: string; shellOverride?: string } + ): Promise> { + return ( + await daemonSpawnAndGetOptions( + argsEnv, + getSelectedCodexHomePath, + getSettings, + processEnvOverrides, + spawnArgs + ) + ).env + } + it('injects OpenCode plugin env (OPENCODE_CONFIG_DIR) on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({}, undefined, undefined, { OPENCODE_CONFIG_DIR: undefined @@ -876,6 +910,68 @@ describe('registerPtyHandlers', () => { it('injects the selected Codex home on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({}, () => '/tmp/orca-codex-home') expect(env.CODEX_HOME).toBe('/tmp/orca-codex-home') + expect(env.ORCA_CODEX_HOME).toBe('/tmp/orca-codex-home') + }) + + it('skips host Codex home when a daemon-backed Windows spawn targets a WSL cwd', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + try { + const spawnOptions = await daemonSpawnAndGetOptions( + {}, + () => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home', + undefined, + { + CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home', + ORCA_CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' + }, + { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\test\\repo' } + ) + const { env } = spawnOptions + expect(env.CODEX_HOME).toBeUndefined() + expect(env.ORCA_CODEX_HOME).toBeUndefined() + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining(['CODEX_HOME', 'ORCA_CODEX_HOME']) + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('skips host Codex home when a daemon-backed Windows spawn uses a WSL shell override', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + try { + const spawnOptions = await daemonSpawnAndGetOptions( + {}, + () => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home', + undefined, + { + CODEX_HOME: 'C:\\Users\\test\\.codex', + ORCA_CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' + }, + { shellOverride: 'wsl.exe' } + ) + expect(spawnOptions.env.CODEX_HOME).toBeUndefined() + expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined() + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining(['CODEX_HOME', 'ORCA_CODEX_HOME']) + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } }) it('injects the agent-hook receiver env on the daemon path', async () => { @@ -2279,7 +2375,7 @@ describe('registerPtyHandlers', () => { registerPtyHandlers( mainWindow as never, undefined, - undefined, + () => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home', () => ({ terminalWindowsShell: 'wsl.exe', @@ -2288,7 +2384,10 @@ describe('registerPtyHandlers', () => { ) handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record } expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object)) + expect(spawnOptions.env.CODEX_HOME).toBeUndefined() + expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined() }) it('keeps shellOverride priority for one-off tabs', () => { @@ -2298,7 +2397,7 @@ describe('registerPtyHandlers', () => { registerPtyHandlers( mainWindow as never, undefined, - undefined, + () => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home', () => ({ terminalWindowsShell: 'powershell.exe', @@ -2311,7 +2410,10 @@ describe('registerPtyHandlers', () => { shellOverride: 'wsl.exe' }) + const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record } expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object)) + expect(spawnOptions.env.CODEX_HOME).toBeUndefined() + expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined() }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 67409c9bf99..5b4596843b2 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -54,6 +54,7 @@ import { clearMigrationUnsupportedPty, clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' +import { parseWslPath } from '../wsl' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -253,6 +254,7 @@ export type BuildPtyHostEnvOptions = { isPackaged: boolean userDataPath: string selectedCodexHomePath: string | null + skipCodexHomeEnv?: boolean githubAttributionEnabled: boolean agentStatusHooksEnabled: boolean } @@ -261,6 +263,30 @@ function readInheritedPath(baseEnv: Record): string { return baseEnv.PATH ?? process.env.PATH ?? process.env.Path ?? '' } +function isWslShellName(shellPath: string | undefined): boolean { + const shellName = shellPath?.replaceAll('\\', '/').split('/').pop()?.toLowerCase() + return shellName === 'wsl.exe' || shellName === 'wsl' +} + +function shouldSkipCodexHomeEnvForWindowsShell( + shellPath: string | undefined, + cwd: string | undefined +): boolean { + return isWslShellName(shellPath) || (typeof cwd === 'string' && parseWslPath(cwd) !== null) +} + +const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const + +function mergePtyEnvDeletions( + existingKeys: string[] | undefined, + additionalKeys: readonly string[] +): string[] | undefined { + if (!existingKeys && additionalKeys.length === 0) { + return undefined + } + return Array.from(new Set([...(existingKeys ?? []), ...additionalKeys])) +} + // Why: when agent status is disabled, a nested Orca terminal can still pass // through a prior PTY's OpenCode/Pi overlay env. Restore the user's original // source dir when Orca recorded one, otherwise strip only values known to be ours. @@ -397,13 +423,18 @@ export function buildPtyHostEnv( }) } - // Why: Codex account switching now materializes auth into one shared - // runtime home (~/.codex), and Codex launched inside Orca terminals must - // use that same prepared home as quota fetches and other entry points. - // Keep the override PTY-scoped so Orca does not mutate the app process - // environment or the user's unrelated external shells. - if (opts.selectedCodexHomePath) { + // Why: Codex account switching now materializes auth into an Orca-scoped + // runtime home, and Codex launched inside Orca terminals must use that same + // prepared home as quota fetches and other entry points. Keep the override + // PTY-scoped so dev/prod Orcas do not share hooks through ~/.codex. + if (opts.skipCodexHomeEnv) { + delete baseEnv.CODEX_HOME + delete baseEnv.ORCA_CODEX_HOME + } else if (opts.selectedCodexHomePath) { baseEnv.CODEX_HOME = opts.selectedCodexHomePath + // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers + // restore this runtime home before Codex can be launched from the prompt. + baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath } // Why: in dev mode the `orca` CLI defaults to the production userData @@ -666,11 +697,14 @@ export function registerPtyHandlers( ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined, pwshAvailable: () => isPwshAvailable(), - buildSpawnEnv: (id, baseEnv) => { + buildSpawnEnv: (id, baseEnv, context) => { const env = buildPtyHostEnv(id, baseEnv, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, + // Why: WSL's inner shell cannot use a Windows userData CODEX_HOME. + // Leave Linux Codex on its native ~/.codex until we own a WSL home. + skipCodexHomeEnv: context?.isWsl === true, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) }) @@ -974,6 +1008,12 @@ export function registerPtyHandlers( if (args.preAllocatedHandle) { env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } + const daemonShellOverride = + process.platform === 'win32' && !args.connectionId + ? getSettings?.()?.terminalWindowsShell + : undefined + const skipCodexHomeEnv = + isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd) if (isDaemonHostSpawn && sessionId) { if (!isSafePtySessionId(sessionId, app.getPath('userData'))) { throw new Error('Invalid PTY session id') @@ -982,6 +1022,7 @@ export function registerPtyHandlers( isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, + skipCodexHomeEnv, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) }) @@ -996,6 +1037,12 @@ export function registerPtyHandlers( if (claudeAuth?.stripAuthEnv) { spawnOptions.envToDelete = [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] } + if (skipCodexHomeEnv) { + spawnOptions.envToDelete = mergePtyEnvDeletions( + spawnOptions.envToDelete, + CODEX_HOME_ENV_KEYS + ) + } if (args.command !== undefined) { spawnOptions.command = args.command } @@ -1333,6 +1380,13 @@ export function registerPtyHandlers( runtime && !(provider instanceof LocalPtyProvider) ? runtime.createPreAllocatedTerminalHandle() : null + const effectiveShellOverride = + args.shellOverride ?? + (process.platform === 'win32' && !args.connectionId + ? getSettings?.()?.terminalWindowsShell + : undefined) + const skipCodexHomeEnv = + isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd) if (isDaemonHostSpawn) { if (effectiveSessionId === undefined) { // Should be unreachable: the expression above returns a string when @@ -1357,6 +1411,7 @@ export function registerPtyHandlers( isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, + skipCodexHomeEnv, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) }) @@ -1381,14 +1436,18 @@ export function registerPtyHandlers( const envToDelete = claudeAuth?.stripAuthEnv ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined + const combinedEnvToDelete = mergePtyEnvDeletions( + envToDelete, + skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [] + ) const spawnOptions: PtySpawnOptions = { cols: args.cols, rows: args.rows, cwd: args.cwd, env: spawnEnv } - if (envToDelete) { - spawnOptions.envToDelete = envToDelete + if (combinedEnvToDelete) { + spawnOptions.envToDelete = combinedEnvToDelete } if (args.command !== undefined) { spawnOptions.command = args.command @@ -1406,11 +1465,6 @@ export function registerPtyHandlers( // or falls back to PowerShell. The LocalPtyProvider already consults // getWindowsShell(); this mirrors that on the daemon path so users who // set WSL as default actually get WSL when pressing Ctrl+T. - const effectiveShellOverride = - args.shellOverride ?? - (process.platform === 'win32' && !args.connectionId - ? getSettings?.()?.terminalWindowsShell - : undefined) if (effectiveShellOverride !== undefined) { spawnOptions.shellOverride = effectiveShellOverride } diff --git a/src/main/powershell-osc133-bootstrap.test.ts b/src/main/powershell-osc133-bootstrap.test.ts index 10d278088a8..f8abc6c6686 100644 --- a/src/main/powershell-osc133-bootstrap.test.ts +++ b/src/main/powershell-osc133-bootstrap.test.ts @@ -11,6 +11,7 @@ describe('PowerShell OSC 133 bootstrap', () => { expect(script).toContain('[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()') expect(script).toContain('ORCA_OPENCODE_CONFIG_DIR') expect(script).toContain('ORCA_PI_CODING_AGENT_DIR') + expect(script).toContain('ORCA_CODEX_HOME') expect(script).toContain('function Global:prompt') expect(script).toContain('function Global:PSConsoleHostReadLine') expect(script).toContain('Esc = [char]27') diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index 8f87ed21d2d..f43952336c8 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -22,6 +22,7 @@ try { # Profiles can re-export user defaults after Orca's spawn env is set. if ($env:ORCA_OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR } if ($env:ORCA_PI_CODING_AGENT_DIR) { $env:PI_CODING_AGENT_DIR = $env:ORCA_PI_CODING_AGENT_DIR } +if ($env:ORCA_CODEX_HOME) { $env:CODEX_HOME = $env:ORCA_CODEX_HOME } $Global:__OrcaOsc133State = @{ OriginalPrompt = $function:prompt diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 3b0b38fe647..5de6f82cf55 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -139,7 +139,11 @@ function safeKillAndClean(id: string, proc: pty.IPty): void { } export type LocalPtyProviderOptions = { - buildSpawnEnv?: (id: string, baseEnv: Record) => Record + buildSpawnEnv?: ( + id: string, + baseEnv: Record, + context?: { isWsl: boolean } + ) => Record /** Whether worktree-scoped shell history is enabled. When true (or absent) * and a worktreeId is provided, HISTFILE is scoped per-worktree. */ isHistoryEnabled?: () => boolean @@ -281,7 +285,10 @@ export class LocalPtyProvider implements IPtyProvider { spawnEnv.PYTHONUTF8 ??= '1' } - const finalEnv = this.opts.buildSpawnEnv ? this.opts.buildSpawnEnv(id, spawnEnv) : spawnEnv + const isWslShell = Boolean(wslInfo) || pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' + const finalEnv = this.opts.buildSpawnEnv + ? this.opts.buildSpawnEnv(id, spawnEnv, { isWsl: isWslShell }) + : spawnEnv if ( process.platform === 'win32' && pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' && @@ -297,7 +304,8 @@ export class LocalPtyProvider implements IPtyProvider { const needsNoMarkerWrapper = finalEnv.ORCA_ATTRIBUTION_SHIM_DIR || finalEnv.ORCA_OPENCODE_CONFIG_DIR || - finalEnv.ORCA_PI_CODING_AGENT_DIR + finalEnv.ORCA_PI_CODING_AGENT_DIR || + finalEnv.ORCA_CODEX_HOME getFallbackShellReadyConfig = args.command ? (shell) => getShellReadyLaunchConfig(shell) : needsNoMarkerWrapper diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index 06f3405b213..b11c72c2e49 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -290,7 +290,7 @@ describePosix('local PTY shell-ready launch config', () => { expect(zshenv).toContain('*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') }) - it('writes wrappers that restore OpenCode and Pi config after user startup files', async () => { + it('writes wrappers that restore agent config homes after user startup files', async () => { const { getBashShellReadyRcfileContent, getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() @@ -303,12 +303,17 @@ describePosix('local PTY shell-ready launch config', () => { '[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' const piRestoreLine = '[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"' + const codexRestoreLine = + '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' expect(zshrc).toContain(restoreLine) expect(zlogin).toContain(restoreLine) expect(bashRc).toContain(restoreLine) expect(zshrc).toContain(piRestoreLine) expect(zlogin).toContain(piRestoreLine) expect(bashRc).toContain(piRestoreLine) + expect(zshrc).toContain(codexRestoreLine) + expect(zlogin).toContain(codexRestoreLine) + expect(bashRc).toContain(codexRestoreLine) }) // Why: regression guard for issue #2422. Without OSC 133 C/D markers in the diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index f43eb3a1e2f..50c87d4ef06 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -148,6 +148,8 @@ __orca_restore_attribution_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" # Why: PI_CODING_AGENT_DIR is also a single-root env var users may re-export. [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. +[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent # status when the foreground command (e.g. an interrupted Claude/Codex CLI) # exits — mirrors the zsh wrapper. Without this, bash users (default on most @@ -253,6 +255,8 @@ if [[ ! -o login ]]; then [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" # Why: PI_CODING_AGENT_DIR must keep the same PTY-scoped overlay after rc files. [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" + # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. + [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" fi __orca_osc133_precmd() { local exit_code=$? @@ -345,6 +349,7 @@ __orca_restore_attribution_path # Why: .zlogin is the final login startup file before the prompt is shown. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" # Why: zsh precmd runs before the prompt is drawn and before zle owns input, # which can double-echo startup commands. line-init fires when zle is ready. if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 0adeaa83b6a..7bf9a20b8cf 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -34,13 +34,15 @@ describe('resolveWindowsShellLaunchArgs', () => { const piRestoreIndex = command.indexOf( '$env:PI_CODING_AGENT_DIR = $env:ORCA_PI_CODING_AGENT_DIR' ) + const codexRestoreIndex = command.indexOf('$env:CODEX_HOME = $env:ORCA_CODEX_HOME') const promptIndex = command.indexOf('function Global:prompt') expect(command).not.toContain('$PROFILE') expect(outputEncodingIndex).toBeGreaterThanOrEqual(0) expect(opencodeRestoreIndex).toBeGreaterThan(outputEncodingIndex) expect(piRestoreIndex).toBeGreaterThan(outputEncodingIndex) - expect(promptIndex).toBeGreaterThan(piRestoreIndex) + expect(codexRestoreIndex).toBeGreaterThan(outputEncodingIndex) + expect(promptIndex).toBeGreaterThan(codexRestoreIndex) expect(command).toContain('Esc = [char]27') expect(command).toContain('Bel = [char]7') expect(command).toContain(')]133;D;$fakeExitCode$(') diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index dcacd78c535..b8da62e9d79 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -31,7 +31,6 @@ import { unregisterSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' -import { appendOrcaCodexAgentStatusProfile } from '../../shared/codex-profile' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' @@ -6923,7 +6922,7 @@ describe('OrcaRuntimeService', () => { 1, expect.objectContaining({ cwd: '/tmp/workspaces/runtime-startup-setup-split', - command: appendOrcaCodexAgentStatusProfile('codex'), + command: 'codex', worktreeId: result.worktree.id }) ) @@ -7026,7 +7025,7 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/tmp/workspaces/runtime-explicit-draft', - command: appendOrcaCodexAgentStatusProfile('codex'), + command: 'codex', worktreeId: result.worktree.id }) ) @@ -7298,7 +7297,7 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/remote/mobile-codex-draft', - command: appendOrcaCodexAgentStatusProfile('codex'), + command: 'codex', connectionId: 'ssh-1', worktreeId: result.worktree.id }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index cddf7aee2ad..7246fd0f925 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6731,8 +6731,7 @@ export class OrcaRuntimeService { draft: content, cmdOverrides: settings.agentCmdOverrides ?? {}, platform: agentLaunchPlatform, - useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings.agentStatusHooksEnabled !== false + useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false }) if (draftLaunchPlan) { return { @@ -6750,8 +6749,7 @@ export class OrcaRuntimeService { cmdOverrides: settings.agentCmdOverrides ?? {}, platform: agentLaunchPlatform, allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings.agentStatusHooksEnabled !== false + useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false }) if (!startupPlan) { return null diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 2552323e9eb..9ecd76cbddc 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -151,6 +151,20 @@ describe('configureDevUserDataPath', () => { }) }) +describe('shouldInstallManagedHooks', () => { + it('keeps managed hook auto-install enabled for default dev runs', async () => { + const { shouldInstallManagedHooks } = await import('./configure-process') + + expect(shouldInstallManagedHooks(true)).toBe(true) + }) + + it('allows managed hook auto-install for packaged runs', async () => { + const { shouldInstallManagedHooks } = await import('./configure-process') + + expect(shouldInstallManagedHooks(false)).toBe(true) + }) +}) + describe('installDevParentDisconnectQuit', () => { it('quits the dev app when the supervising IPC channel disconnects', async () => { const { app } = await import('electron') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 3d52ee258d3..2fe01bc9122 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -135,6 +135,17 @@ export function configureDevUserDataPath(isDev: boolean): void { app.setPath('userData', join(app.getPath('appData'), 'orca-dev')) } +export function shouldInstallManagedHooks(isDev: boolean): boolean { + void isDev + // Why: managed hook installation now targets Orca-owned, environment-scoped + // homes for Codex rather than the user's default ~/.codex state, so plain + // dev runs need the install path enabled to keep hook-backed agent statuses + // accurate without an opt-in flag. The remaining agents still rely on the + // shared startup installer loop, so keep the policy uniformly on until + // they are migrated to more granular ownership seams. + return true +} + export function installDevParentDisconnectQuit(isDev: boolean): void { if (!isDev || typeof process.send !== 'function') { return diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 5cec9cc3fa7..35fd859149e 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -49,8 +49,7 @@ export function FloatingTerminalWindowControls({ cmdOverrides: state.settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: state.settings?.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: state.settings?.agentStatusHooksEnabled !== false + useOrcaClaudeAgentStatusSettings: state.settings?.agentStatusHooksEnabled !== false }) if (!startupPlan) { toast.error(`Could not build launch command for ${defaultAgentLabel ?? defaultAgent}.`) diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts index c2f14928752..dc2c49e855b 100644 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { appendOrcaCodexAgentStatusProfile } from '../../../../shared/codex-profile' import { getDefaultOnboardingState, getDefaultSettings } from '../../../../shared/constants' import { buildDismissedOnboardingFolderAgentStartup, @@ -15,7 +14,7 @@ describe('buildOnboardingFolderAgentStartup', () => { }) expect(startup).toEqual({ - command: appendOrcaCodexAgentStatusProfile('codex'), + command: 'codex', telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index f4f2bef480d..6bb952faebc 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1762,8 +1762,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS prompt: startupPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false }) // Why: thread agent_started telemetry through the queued startup so @@ -1981,8 +1980,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS draft: quickDraftPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false }) let startupPlan: ReturnType = null @@ -2001,8 +1999,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false }) if (startupPlan && quickDraftPrompt) { startupPlan.draftPrompt = quickDraftPrompt diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index a35f13ae515..ba60358638f 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -77,8 +77,7 @@ export async function launchAgentBackgroundSession( cmdOverrides, platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks }) pasteDraftAfterLaunch = trimmedPrompt } else { @@ -88,8 +87,7 @@ export async function launchAgentBackgroundSession( cmdOverrides, platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: !hasPrompt, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks }) } if (!startupPlan) { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 0a83c2b5f0c..c80aa775800 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -97,8 +97,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI cmdOverrides, platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks }) pasteDraftAfterLaunch = trimmedPrompt submitPastedPrompt = true @@ -109,8 +108,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI draft: trimmedPrompt, cmdOverrides, platform: CLIENT_PLATFORM, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks }) if (draftLaunchPlan) { startupPlan = { @@ -127,8 +125,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI cmdOverrides, platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks }) pasteDraftAfterLaunch = trimmedPrompt } @@ -139,8 +136,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI cmdOverrides, platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks }) pasteDraftAfterLaunch = trimmedPrompt } else { @@ -150,8 +146,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI cmdOverrides, platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: !hasPrompt, - useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, - useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks }) } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index f2d0ef0c30f..8377d86e363 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -299,8 +299,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom draft: draftContent, cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false }) if (draftLaunchPlan) { startupPlan = { @@ -318,8 +317,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false }) } diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index 21dcf02fb01..60b25e7dae4 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -30,8 +30,7 @@ export function buildOnboardingFolderAgentStartup( cmdOverrides: settings.agentCmdOverrides ?? {}, platform: getClientPlatform(), allowEmptyPromptLaunch: true, - useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: settings.agentStatusHooksEnabled !== false + useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false }) if (!startupPlan) { return undefined diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 523ef251669..aecc46612b7 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { appendOrcaCodexAgentStatusProfile } from '../../../shared/codex-profile' import type { Worktree } from '../../../shared/types' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' @@ -82,7 +81,7 @@ describe('activateAndRevealWorktree created agent reopen', () => { expect(result).toEqual({ primaryTabId: reopenedTab?.id }) expect(reopenedTab).toBeDefined() expect(state.pendingStartupByTabId[reopenedTab!.id]).toEqual({ - command: appendOrcaCodexAgentStatusProfile('codex'), + command: 'codex', telemetry: { agent_kind: 'codex', launch_source: 'sidebar', diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 2cfce6d4998..1d0faf0739e 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -98,8 +98,6 @@ function buildCreatedAgentReopenStartup(worktree: Worktree): platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true, useOrcaClaudeAgentStatusSettings: - useAppStore.getState().settings?.agentStatusHooksEnabled !== false, - useOrcaCodexAgentStatusProfile: useAppStore.getState().settings?.agentStatusHooksEnabled !== false }) if (!startupPlan) { diff --git a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts index e2aaea6fb84..0634a8f951c 100644 --- a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts +++ b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { appendOrcaCodexAgentStatusProfile } from '../../../../shared/codex-profile' import { getDefaultOnboardingState, getDefaultSettings } from '../../../../shared/constants' import { createTestStore, makeWorktree } from './store-test-helpers' @@ -58,7 +57,7 @@ describe('repo slice skipped-onboarding folder startup', () => { 'folder-1::/folder', { startup: { - command: appendOrcaCodexAgentStatusProfile('codex'), + command: 'codex', telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/shared/codex-profile.ts b/src/shared/codex-profile.ts deleted file mode 100644 index c3268ac066f..00000000000 --- a/src/shared/codex-profile.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const ORCA_CODEX_AGENT_STATUS_PROFILE = 'orca-agent-status' - -export function appendOrcaCodexAgentStatusProfile(command: string): string { - return `${command} --profile-v2 ${ORCA_CODEX_AGENT_STATUS_PROFILE}` -} diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index f244f3bef52..7cf16ae0ca6 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -36,16 +36,29 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"') }) - it('launches Codex with the Orca profile when agent status hooks are enabled', () => { + it('does not launch Codex with the Orca profile when agent status hooks are enabled', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: 'fix it', + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan?.launchCommand).toBe("codex 'fix it'") + }) + + it('does not inject Codex profile flags through the shared agent-status option', () => { const plan = buildAgentStartupPlan({ agent: 'codex', prompt: 'fix it', cmdOverrides: {}, platform: 'linux', - useOrcaCodexAgentStatusProfile: true + useOrcaClaudeAgentStatusSettings: true }) - expect(plan?.launchCommand).toBe("codex --profile-v2 orca-agent-status 'fix it'") + expect(plan?.launchCommand).toBe("codex 'fix it'") + expect(plan?.launchCommand).not.toContain('--profile') + expect(plan?.launchCommand).not.toContain('orca-agent-status') }) it('launches Claude with the Orca settings file when agent status hooks are enabled', () => { @@ -102,8 +115,7 @@ describe('tui agent startup plans', () => { agent: 'codex', prompt: 'fix it', cmdOverrides: { codex: 'codex --profile work' }, - platform: 'linux', - useOrcaCodexAgentStatusProfile: true + platform: 'linux' }) expect(plan?.launchCommand).toBe("codex --profile work 'fix it'") diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 1fc1b13dd64..4c724ea0706 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -1,6 +1,5 @@ import { isShellProcess } from './agent-detection' import { appendOrcaClaudeAgentStatusSettings } from './claude-settings' -import { appendOrcaCodexAgentStatusProfile } from './codex-profile' import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { TuiAgent } from './types' @@ -51,7 +50,6 @@ function resolveBaseCommand(args: { cmdOverrides: Partial> shell: AgentStartupShell useOrcaClaudeAgentStatusSettings?: boolean - useOrcaCodexAgentStatusProfile?: boolean }): string { const override = args.cmdOverrides[args.agent] if (override) { @@ -61,9 +59,9 @@ function resolveBaseCommand(args: { if (args.agent === 'claude' && args.useOrcaClaudeAgentStatusSettings) { return appendOrcaClaudeAgentStatusSettings(command, args.shell) } - return args.agent === 'codex' && args.useOrcaCodexAgentStatusProfile - ? appendOrcaCodexAgentStatusProfile(command) - : command + // Why: Codex status hooks live in Orca's runtime CODEX_HOME; adding + // --profile-v2 makes Codex load a second hook representation and warn. + return command } export function buildAgentStartupPlan(args: { @@ -74,7 +72,6 @@ export function buildAgentStartupPlan(args: { shell?: AgentStartupShell allowEmptyPromptLaunch?: boolean useOrcaClaudeAgentStatusSettings?: boolean - useOrcaCodexAgentStatusProfile?: boolean }): AgentStartupPlan | null { const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args const shell = resolveStartupShell(platform, args.shell) @@ -84,8 +81,7 @@ export function buildAgentStartupPlan(args: { agent, cmdOverrides, shell, - useOrcaClaudeAgentStatusSettings: args.useOrcaClaudeAgentStatusSettings, - useOrcaCodexAgentStatusProfile: args.useOrcaCodexAgentStatusProfile + useOrcaClaudeAgentStatusSettings: args.useOrcaClaudeAgentStatusSettings }) if (!trimmedPrompt) { @@ -160,7 +156,6 @@ export function buildAgentDraftLaunchPlan(args: { platform: NodeJS.Platform shell?: AgentStartupShell useOrcaClaudeAgentStatusSettings?: boolean - useOrcaCodexAgentStatusProfile?: boolean }): AgentDraftLaunchPlan | null { const { agent, draft, cmdOverrides, platform } = args const shell = resolveStartupShell(platform, args.shell) @@ -173,8 +168,7 @@ export function buildAgentDraftLaunchPlan(args: { agent, cmdOverrides, shell, - useOrcaClaudeAgentStatusSettings: args.useOrcaClaudeAgentStatusSettings, - useOrcaCodexAgentStatusProfile: args.useOrcaCodexAgentStatusProfile + useOrcaClaudeAgentStatusSettings: args.useOrcaClaudeAgentStatusSettings }) if (config.draftPromptFlag) { const quoted = quoteStartupArg(trimmed, shell) diff --git a/tests/e2e/terminal-codex-home.spec.ts b/tests/e2e/terminal-codex-home.spec.ts new file mode 100644 index 00000000000..1f85a4f4c9b --- /dev/null +++ b/tests/e2e/terminal-codex-home.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from './helpers/orca-app' +import { + execInTerminal, + getTerminalContent, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +type CodexHomeProbe = { + codexHome: string | null + orcaCodexHome: string | null +} + +function readCodexHomeProbe(pageContent: string, marker: string): CodexHomeProbe | null { + const match = new RegExp(`${marker}:(\\{[^\\r\\n]+\\})`).exec(pageContent) + if (!match) { + return null + } + return JSON.parse(match[1] ?? 'null') as CodexHomeProbe | null +} + +test.describe('Terminal Codex runtime home', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + }) + + test('terminal process receives the Orca-managed Codex home', async ({ orcaPage }) => { + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + const marker = `__ORCA_CODEX_HOME_E2E_${Date.now()}__` + const command = [ + 'node -e', + `"console.log('${marker}:' + JSON.stringify({codexHome: process.env.CODEX_HOME || null, orcaCodexHome: process.env.ORCA_CODEX_HOME || null}))"` + ].join(' ') + + await execInTerminal(orcaPage, ptyId, command) + + let probe: CodexHomeProbe | null = null + await expect + .poll( + async () => { + probe = readCodexHomeProbe(await getTerminalContent(orcaPage), marker) + return Boolean( + probe?.codexHome && + probe.orcaCodexHome && + probe.codexHome === probe.orcaCodexHome && + /[\\/]codex-runtime-home[\\/]home$/.test(probe.codexHome) + ) + }, + { timeout: 15_000, message: 'Terminal did not expose Orca-managed Codex home env' } + ) + .toBe(true) + + expect(probe?.codexHome).toBe(probe?.orcaCodexHome) + }) +})