Isolate Codex hooks in Orca runtime home (#2350)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-05-25 15:07:40 -07:00
committed by GitHub
co-authored by Orca
parent 3a40c62169
commit a2c71461b4
59 changed files with 3923 additions and 722 deletions
+3
View File
@@ -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",
+2
View File
@@ -18,6 +18,8 @@ export type HookCommandConfig = {
type: 'command'
command: string
timeout?: number
async?: boolean
statusMessage?: string
[key: string]: unknown
}
@@ -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()],
@@ -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<string, unknown>
}
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')
@@ -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)],
+104
View File
@@ -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<string, unknown>): Promise<void> => {
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' })
+54 -1
View File
@@ -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<typeof import('node:os')>('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 })
}
+4
View File
@@ -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 {
@@ -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> = {}): 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(
+285 -59
View File
@@ -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 {
@@ -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<typeof NodeOs>('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())
})
})
+12 -1
View File
@@ -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', () => {
+21 -11
View File
@@ -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<string[]> {
}
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<string[]> {
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<CodexUsageProcessedFile> {
+264
View File
@@ -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<typeof NodeOs>('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)
})
})
+327
View File
@@ -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>()
): 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
}
+220
View File
@@ -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<typeof NodeFs>('node:fs')
return {
...actual,
symlinkSync: (...args: Parameters<typeof actual.symlinkSync>) => {
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<typeof NodeOs>('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')
})
})
+229
View File
@@ -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
}
}
+40 -1
View File
@@ -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"',
+16 -3
View File
@@ -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}`
}
+724 -56
View File
@@ -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<string, { hooks?: { command?: string }[] }[]>
}
@@ -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<string, { hooks?: { command?: string }[] }[]>
}
const prodHooks = JSON.parse(readFileSync(prodHooksPath, 'utf-8')) as {
hooks: Record<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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"')
})
})
+529 -315
View File
@@ -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<string, CodexEventLabel> = {
...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<string, HookDefinition[]> | undefined,
isManagedCommand: (command: string | undefined) => boolean
): {
hooks: Record<string, HookDefinition[]>
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<string, HookDefinition[]> = {}
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<string, HookDefinition[]>,
isManagedCommand: (command: string | undefined) => boolean
): Map<string, boolean> {
const signatures = new Map<string, boolean>()
let trustEntries: Map<string, CodexHookTrustState>
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<string, HookDefinition[]>,
trustedSystemHookSignatures: ReadonlyMap<string, boolean>,
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<string>()
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<CodexEventLabel>(
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<string, CodexHookTrustState>
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<string>(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<string>(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<AgentHookInstallStatus> {
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<CodexEventLabel>(
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()
}
+1
View File
@@ -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
+2
View File
@@ -12,6 +12,7 @@ export type DaemonSpawnOptions = {
sessionId: string
cwd?: string
env?: Record<string, string>
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
})
+1
View File
@@ -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,
+57 -1
View File
@@ -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)
+6 -1
View File
@@ -32,6 +32,7 @@ export type PtySubprocessOptions = {
rows: number
cwd?: string
env?: Record<string, string>
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<string, string>
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) {
+5
View File
@@ -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
+4
View File
@@ -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}"
+3
View File
@@ -12,6 +12,7 @@ export type CreateOrAttachOptions = {
rows: number
cwd?: string
env?: Record<string, string>
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<string, string>
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
+5 -4
View File
@@ -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<string, string>
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
+51 -20
View File
@@ -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
+1 -1
View File
@@ -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',
+14 -19
View File
@@ -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 () => {
+111 -9
View File
@@ -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<string, string>
envToDelete?: string[]
}
async function daemonSpawnAndGetOptions(
argsEnv?: Record<string, string>,
getSelectedCodexHomePath?: () => string | null,
getSettings?: () => { enableGitHubAttribution: boolean },
processEnvOverrides?: Record<string, string | undefined>
): Promise<Record<string, string>> {
processEnvOverrides?: Record<string, string | undefined>,
spawnArgs?: { cwd?: string; shellOverride?: string }
): Promise<DaemonSpawnCall> {
const daemonSpawn = setupDaemonAdapter()
const savedEnv: Record<string, string | undefined> = {}
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<string, string>,
getSelectedCodexHomePath?: () => string | null,
getSettings?: () => { enableGitHubAttribution: boolean },
processEnvOverrides?: Record<string, string | undefined>,
spawnArgs?: { cwd?: string; shellOverride?: string }
): Promise<Record<string, string>> {
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<string, string> }
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<string, string> }
expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object))
expect(spawnOptions.env.CODEX_HOME).toBeUndefined()
expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined()
})
})
+68 -14
View File
@@ -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, string>): 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
}
@@ -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')
+1
View File
@@ -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
+11 -3
View File
@@ -139,7 +139,11 @@ function safeKillAndClean(id: string, proc: pty.IPty): void {
}
export type LocalPtyProviderOptions = {
buildSpawnEnv?: (id: string, baseEnv: Record<string, string>) => Record<string, string>
buildSpawnEnv?: (
id: string,
baseEnv: Record<string, string>,
context?: { isWsl: boolean }
) => Record<string, string>
/** 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
@@ -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
@@ -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
@@ -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$(')
+3 -4
View File
@@ -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
})
+2 -4
View File
@@ -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
@@ -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')
+11
View File
@@ -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
@@ -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}.`)
@@ -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',
+3 -6
View File
@@ -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<typeof buildAgentStartupPlan> = 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
@@ -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) {
@@ -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
})
}
@@ -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
})
}
@@ -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
@@ -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',
@@ -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) {
@@ -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',
-5
View File
@@ -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}`
}
+17 -5
View File
@@ -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'")
+5 -11
View File
@@ -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<Record<TuiAgent, string>>
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)
+59
View File
@@ -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)
})
})