mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Isolate Codex hooks in Orca runtime home (#2350)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"',
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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$(')
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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'")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user