Files
orca/src/main/codex/codex-hook-command-form.test.ts
T
Jinwoo Hong ac8ea9f958 fix(codex): write Orca's hook into ~/.codex only when something changed (#25743)
* fix(codex): write Orca's hook in ~/.codex only when something changed

One reconcile replaces the per-launch writer and its background approval
session. It runs at app start after PATH hydration, when the setting turns on,
once per native pane spawn, and (with a bounded 3 s wait) on Codex launches and
resumes. Orca's entry lives alone in a matcherless group, appended last unless
already in place; other copies are removed and the shifted user approvals move
verbatim under both key spellings. The approval, with Codex's own hash, goes in
first. The routing gate closes only for a hooks.json with a bad shape.

* fix(codex): report hook status for the home the next pane uses

Status reads ~/.codex (both key spellings) when launches use it, or the CLI
has no selection, and the selected managed home otherwise. It explains: update
Codex, Codex not found, not asked yet, approved by Orca but not yet confirmed
by Codex, a hooks.json shape that moves panes to Orca's own home, and inline
config.toml approvals Orca cannot add to.

* test(codex): pin the ~/.codex approval against a real Codex, and run it on those files

The contract now also writes Orca's entry into a throwaway ~/.codex and checks
that Codex lists it trusted and enabled, turns it back on over a /hooks
switch-off, lists it for review after a user inserts a hook ahead until the next
check, keeps an inline config.toml loadable, and shows no review in a real TUI
start. The real-binary CI job now runs when the ~/.codex writer changes.

* test(ci): keep the scope test under its line cap; assert the ~/.codex paths beside the contract job

* test(codex): type the hoisted test holders instead of asserting

* test(codex): cover the matcher rule, an older build's event, and the spawn trigger

Adds tests that failed against mutants which survived the first pass: an
entry alone in a matcher group, a current copy's approval in an event left to
an older build, one run per spawn, a spawn riding a running reconcile, and the
native-only spawn trigger. The opt-out's Codex-hash cleanup now runs once,
after the sweep, instead of twice.

* docs(codex): name the ~/.codex reconcile in the legacy sweep's lane comment

* fix(codex): approve ~/.codex with Orca's own hash while Codex's answer is pending

The reconcile waits at most 0.5 s for Codex's hash. If the lookup is still
running, an entry already in place keeps its approval and nothing is written;
a missing entry or approval gets Orca's computed hash, approval first, inside a
launch's 3 s wait, as main's did. When the lookup lands, the reconcile runs
again and Codex's hash replaces it.

* refactor(codex): one start for the hook lookup and the ~/.codex reconcile

startCodexHooks replaces the two start functions and takes the PATH wait that
the reconcile request's after option carried. A spawn reconciles unless one is
running, without a scheduled flag, launches call one reconcileCodexHooksForLaunch
on the shared withTimeout, and the reconcile alone reads the hooks setting. The
startup test now runs the ready phase instead of matching its source text.

* refactor(codex): plan ~/.codex with the shared Orca-hook pruner and approval reader

The planner prunes with removeManagedCommands, as the opt-out does (so a hook
that runs Orca's script through its args goes too), and returns a prune or
settle union. While Codex has not answered, the kept approval comes from the
shared per-event reader. The reconcile result is its outcome alone, a
concurrent save spends a pass of the one bound, the opt-out removes Orca's
approvals once, the approval-first writer takes the hooks path, and
getRealHomeConfigTomlPath gives way to getSystemCodexConfigTomlPath.

* fix(codex): ~/.codex keeps only an approval holding a hash Orca's entry may carry

* refactor(codex): name the ~/.codex hooks-file check for what it reports

* test(codex): the ~/.codex entry tests know Codex's earlier hashes, as the app's lookup does

* refactor(codex): the ~/.codex reconcile uses the lookup's answer names

* test(codex): ~/.codex status tests keep a Codex on PATH unless one is missing

* refactor(codex): one stopgap for both homes; the ~/.codex pass finds its own home, hash and user data

Also passes every approval to the approval-first writer, which already skips the ones in place.

* refactor(codex): the reconcile start owns the warm-up; no rerun-on-answer flag

The lookup start only records the hydrated PATH; one catch, one request type, and the app config is kept as given.

* refactor(codex): the ~/.codex pass returns nothing; tests read the files

Also makes the ~/.codex opt-out take Codex's hashes, as every production caller passes them.

* refactor(codex): the ~/.codex approval cleanup takes Codex's hashes

* test(startup): check the Codex hook start waits for PATH by behavior, not identity

* fix(codex): a status-hook problem never moves the system default off ~/.codex

* chore(ci): run the real-Codex contract when the ~/.codex reconcile changes

* docs(codex): the ~/.codex reconcile's refused branch covers every definitive refusal

* test(codex): start the failure-memo lookup with the reconcile's PATH-only start

* fix(codex): ~/.codex gets nothing while Codex is missing, a pending answer uses the saved one, a conversion waits for a run that writes, and status and the reconcile read one home and one Orca-hash rule

* test(codex): ~/.codex and managed-home status tests name their home; ~/.codex rewrites the backslash key Codex on Windows reads

* test(codex): the Windows upgrade test reads status for the managed home it installs

* test(codex): the real-TUI contract trusts its workdir by its real path, and its userData exists

* fix(codex): keep Orca's approval at every slot its entry holds in ~/.codex
2026-10-06 16:12:23 -04:00

272 lines
12 KiB
TypeScript

import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { runProcess } from '../../shared/child-process/run-process'
import {
buildWindowsHookPowerShellCommand,
createManagedCommandMatcher,
wrapWindowsHookCommand,
type HookDefinition
} from '../agent-hooks/installer-utils'
import {
buildCodexHookCommand,
CODEX_HOOK_COMMAND_FORM,
readCodexHookCommandForm
} from './codex-hook-command-form'
import { getCodexManagedHookInstallMaterial } from './codex-hook-definition'
import { planRealHomeCodexHookEntries } from './codex-real-home-hook-entry-plan'
// Why goldens: these bytes are shared by every Orca on a HOME. Changing them
// without a form bump makes builds rewrite each other's entry again.
const POSIX_GOLDEN =
': orca-agent-hook-form=1; if [ -n "${ORCA_PANE_KEY-}" ] && [ -n "${ORCA_AGENT_HOOK_ROOT-}" ] && [ -f "${ORCA_AGENT_HOOK_ROOT-}/agent-hooks/codex-hook.sh" ]; then /bin/sh "${ORCA_AGENT_HOOK_ROOT-}/agent-hooks/codex-hook.sh" || :; elif [ -z "${ORCA_AGENT_HOOK_ROOT-}" ] && [ -n "${ORCA_PANE_KEY-}" ] && [ -n "${ORCA_AGENT_HOOK_PORT-}" ] && [ -f "${HOME-}/.orca/agent-hooks/codex-hook.sh" ]; then /bin/sh "${HOME-}/.orca/agent-hooks/codex-hook.sh" || :; else { command -p cat 2>/dev/null || cat; } >/dev/null 2>&1 || :; fi'
const WINDOWS_BARE_GOLDEN = 'C:/Users/alice/.orca/agent-hooks/codex-hook.cmd'
const WINDOWS_CMD_GOLDEN =
'C:\\Windows\\System32\\cmd.exe --% /d /v:off /c @"C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd"'
// Why kept: local builds before the absolute cmd.exe wrote it; the managed installer and the app-start reconcile convert it.
const WINDOWS_BARE_CMD_SPELLING =
'cmd --% /d /c @"C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd"'
const WINDOWS_ENV = { SystemRoot: 'C:\\Windows', ComSpec: 'C:\\Windows\\system32\\cmd.exe' }
// Why kept: dev builds of this form wrote it for a spaced profile path; app start converts it.
const WINDOWS_POWERSHELL_TEXT =
"<# orca-agent-hook-form=1 #> if ($env:ORCA_PANE_KEY -and $env:ORCA_AGENT_HOOK_ROOT -and (Test-Path -LiteralPath (Join-Path $env:ORCA_AGENT_HOOK_ROOT 'agent-hooks\\codex-hook.cmd') -PathType Leaf)) { & (Join-Path $env:ORCA_AGENT_HOOK_ROOT 'agent-hooks\\codex-hook.cmd') } elseif (-not $env:ORCA_AGENT_HOOK_ROOT -and $env:ORCA_PANE_KEY -and $env:ORCA_AGENT_HOOK_PORT -and (Test-Path -LiteralPath 'C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd' -PathType Leaf)) { & 'C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd' } else { if (-not $env:ORCA_AGENT_HOOK_PORT -or -not $env:ORCA_AGENT_HOOK_TOKEN -or -not $env:ORCA_PANE_KEY) { exit 0 }; [Console]::In.ReadToEnd() | Out-Null }; exit 0"
function windowsCommandFor(
profileDirName: string,
env: Record<string, string | undefined> = WINDOWS_ENV
): string {
return buildCodexHookCommand(
`C:\\Users\\${profileDirName}\\.orca\\agent-hooks\\codex-hook.cmd`,
'win32',
env
)
}
describe('frozen Codex hook command', () => {
it('matches the form 1 goldens', () => {
expect(CODEX_HOOK_COMMAND_FORM).toBe(1)
expect(buildCodexHookCommand('/home/a/.orca/agent-hooks/codex-hook.sh', 'linux')).toBe(
POSIX_GOLDEN
)
expect(windowsCommandFor('alice')).toBe(WINDOWS_BARE_GOLDEN)
expect(windowsCommandFor('First Last')).toBe(WINDOWS_CMD_GOLDEN)
})
it.each([' ', '&', '^', '$', '`', "'", '!', '(', ')', 'é', '测'])(
'takes the cmd spelling for a profile path holding %j',
(character) => {
const name = `a${character}b`
expect(windowsCommandFor(name)).toBe(
`C:\\Windows\\System32\\cmd.exe --% /d /v:off /c @"C:/Users/${name}/.orca/agent-hooks/codex-hook.cmd"`
)
}
)
it.each(['alice', 'Alice.Smith-2', 'a_b', 'ALICE~1'])(
'keeps the bare path for the safe profile path %s',
(name) => {
expect(windowsCommandFor(name)).toBe(`C:/Users/${name}/.orca/agent-hooks/codex-hook.cmd`)
}
)
it('writes the same Windows bytes for a path in either slash direction', () => {
for (const name of ['alice', 'First Last']) {
const backslashed = `C:\\Users\\${name}\\.orca\\agent-hooks\\codex-hook.cmd`
expect(buildCodexHookCommand(backslashed, 'win32', WINDOWS_ENV)).toBe(
buildCodexHookCommand(backslashed.replaceAll('\\', '/'), 'win32', WINDOWS_ENV)
)
}
})
it('names the system cmd.exe from %SystemRoot%, on any drive, with backslashes', () => {
expect(windowsCommandFor('First Last', { SystemRoot: 'D:\\Windows' })).toBe(
'D:\\Windows\\System32\\cmd.exe --% /d /v:off /c @"C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd"'
)
expect(windowsCommandFor('First Last', { SystemRoot: 'C:\\WINDOWS\\' })).toBe(
WINDOWS_CMD_GOLDEN.replace('C:\\Windows\\', 'C:\\WINDOWS\\')
)
// Why: a safe profile path never names cmd.exe, whatever the Windows directory.
expect(windowsCommandFor('alice', { SystemRoot: 'D:\\Windows' })).toBe(WINDOWS_BARE_GOLDEN)
})
it('falls back to the Windows directory holding %ComSpec% when %SystemRoot% is unset', () => {
expect(windowsCommandFor('First Last', { ComSpec: 'E:\\WinNT\\system32\\cmd.exe' })).toBe(
'E:\\WinNT\\System32\\cmd.exe --% /d /v:off /c @"C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd"'
)
// Why: both sources name the same directory on a machine, so a process missing one writes the same bytes.
expect(windowsCommandFor('First Last', { ComSpec: 'C:\\Windows\\system32\\cmd.exe' })).toBe(
WINDOWS_CMD_GOLDEN
)
})
it.each([
['unset', {}],
['relative', { SystemRoot: 'Windows' }],
['spaced', { SystemRoot: 'C:\\My Windows' }],
['holding &', { SystemRoot: 'C:\\Win&dows' }],
['a UNC share', { SystemRoot: '\\\\server\\Windows' }],
['another shell in %ComSpec%', { ComSpec: 'C:\\Tools\\tcc.exe' }],
['a spaced %ComSpec%', { ComSpec: 'C:\\My Windows\\System32\\cmd.exe' }]
])(
'names C:\\Windows\\System32\\cmd.exe, never a bare or quoted cmd, when the Windows directory is %s',
(_case, env) => {
expect(windowsCommandFor('First Last', env)).toBe(WINDOWS_CMD_GOLDEN)
}
)
it('writes identical bytes for the same profile path and Windows directory', () => {
const first = windowsCommandFor('First Last', { SystemRoot: 'D:\\Windows' })
expect(windowsCommandFor('First Last', { SystemRoot: 'D:\\Windows' })).toBe(first)
expect(windowsCommandFor('First Last', { SystemRoot: 'D:/Windows/' })).toBe(first)
})
it('writes identical POSIX bytes whatever the home or build', () => {
expect(buildCodexHookCommand('/Users/a/.orca/agent-hooks/codex-hook.sh', 'darwin')).toBe(
buildCodexHookCommand('/home/b/.orca/agent-hooks/codex-hook.sh', 'linux')
)
})
it.each([POSIX_GOLDEN, WINDOWS_BARE_GOLDEN, WINDOWS_CMD_GOLDEN])(
'keeps the script name in plain text so every older build still recognizes it',
(command) => {
const isOrca = createManagedCommandMatcher(
command === POSIX_GOLDEN ? 'codex-hook.sh' : 'codex-hook.cmd'
)
expect(isOrca(command)).toBe(true)
}
)
it('reads the form: current, higher, and unmarked older forms', () => {
expect(readCodexHookCommandForm(POSIX_GOLDEN, POSIX_GOLDEN)).toBe(1)
expect(readCodexHookCommandForm(WINDOWS_BARE_GOLDEN, WINDOWS_BARE_GOLDEN)).toBe(1)
expect(readCodexHookCommandForm(POSIX_GOLDEN.replace('form=1', 'form=2'), POSIX_GOLDEN)).toBe(2)
expect(
readCodexHookCommandForm(
"if [ -f '/h/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/h/.orca/agent-hooks/codex-hook.sh'; fi",
POSIX_GOLDEN
)
).toBe(0)
expect(
readCodexHookCommandForm(
'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd',
WINDOWS_BARE_GOLDEN
)
).toBe(0)
})
})
describe('the Windows spelling change', () => {
const hooksWith = (command: string): Record<string, HookDefinition[]> => ({
Stop: [
{ hooks: [{ type: 'command', command, timeout: 10 }] },
{ hooks: [{ type: 'command', command: 'user-hook.cmd' }] }
]
})
const plan = (command: string, convertOlderForms: boolean) =>
planRealHomeCodexHookEntries({
hooks: hooksWith(command),
sourcePath: 'C:/Users/First Last/.codex/hooks.json',
material: {
...getCodexManagedHookInstallMaterial(),
events: ['Stop'],
command: WINDOWS_CMD_GOLDEN
},
isOrcaCommand: createManagedCommandMatcher('codex-hook.cmd'),
convertOlderForms
})
it.each([
['the PowerShell-text form', WINDOWS_POWERSHELL_TEXT],
['the bare-cmd spelling', WINDOWS_BARE_CMD_SPELLING],
[
"main's PowerShell text",
buildWindowsHookPowerShellCommand('C:\\Users\\First Last\\.orca\\agent-hooks\\codex-hook.cmd')
],
[
'the encoded launcher',
wrapWindowsHookCommand('C:\\Users\\First Last\\.orca\\agent-hooks\\codex-hook.cmd')
]
])('converts %s to the cmd.exe spelling once, in its slot, at app start', (_case, older) => {
const converted = plan(older, true)
expect(converted).toMatchObject({ kind: 'settle', changedLabels: new Set(['stop']) })
expect(converted.hooks.Stop).toEqual([
{ hooks: [{ type: 'command', command: WINDOWS_CMD_GOLDEN, timeout: 10 }] },
{ hooks: [{ type: 'command', command: 'user-hook.cmd' }] }
])
expect(plan(WINDOWS_CMD_GOLDEN, true)).toMatchObject({ changedLabels: new Set() })
})
it.each([WINDOWS_POWERSHELL_TEXT, WINDOWS_BARE_CMD_SPELLING])(
'leaves the older Windows form %# alone on a pane launch',
(older) => {
expect(plan(older, false)).toMatchObject({ changedLabels: new Set() })
}
)
})
describe.skipIf(process.platform === 'win32')('frozen Codex hook command under /bin/sh', () => {
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
function makeScripts(): { home: string; root: string; marker: string } {
const dir = mkdtempSync(join(tmpdir(), 'orca-codex-hook-form-'))
roots.push(dir)
const home = join(dir, 'home')
const root = join(dir, 'root')
const marker = join(dir, 'ran')
for (const [base, label] of [
[join(home, '.orca'), 'shared'],
[root, 'root']
]) {
mkdirSync(join(base, 'agent-hooks'), { recursive: true })
writeFileSync(
join(base, 'agent-hooks', 'codex-hook.sh'),
`cat >/dev/null; printf ${label} >> '${marker}'; exit 2\n`
)
}
return { home, root, marker }
}
async function run(env: Record<string, string>): Promise<{ code: number | null; ran: string }> {
const result = await runProcess({
program: '/bin/sh',
args: ['-c', POSIX_GOLDEN],
input: '{"hook_event_name":"Stop"}',
env: { PATH: process.env.PATH ?? '', ...env },
timeoutMs: 10_000
})
const ran = existsSync(env.MARKER) ? readFileSync(env.MARKER, 'utf-8') : ''
return { code: result.code, ran }
}
it.each([
['outside Orca', {}, ''],
['a pane with hooks off', { ORCA_PANE_KEY: 'tab:leaf' }, ''],
['a pane with hooks on', { ORCA_PANE_KEY: 'tab:leaf', ORCA_AGENT_HOOK_PORT: '1' }, 'shared'],
[
'a structured child with a root',
{ ORCA_AGENT_HOOK_PORT: '1', ORCA_AGENT_HOOK_ROOT: 'R' },
''
],
[
'a pane with a hook root',
{ ORCA_PANE_KEY: 'tab:leaf', ORCA_AGENT_HOOK_PORT: '1', ORCA_AGENT_HOOK_ROOT: 'R' },
'root'
]
])('runs the right script for %s and always exits 0', async (_case, env, expected) => {
const scripts = makeScripts()
const resolved: Record<string, string> = Object.fromEntries(
Object.entries(env).map(([key, value]) => [key, value === 'R' ? scripts.root : value])
)
expect(await run({ ...resolved, HOME: scripts.home, MARKER: scripts.marker })).toEqual({
code: 0,
ran: expected
})
})
})