diff --git a/src/main/agent-hooks/hook-script-outside-orca.test.ts b/src/main/agent-hooks/hook-script-outside-orca.test.ts new file mode 100644 index 00000000000..6e9a27f6acc --- /dev/null +++ b/src/main/agent-hooks/hook-script-outside-orca.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { _internals as codexInternals } from '../codex/hook-service' +import { buildPosixHookSpoolLines } from './hook-stdin-contract' + +/** Managed hooks are installed into the user's agent config, so they also run when the + * agent is launched from a plain terminal. There they must be inert and silent. */ +function runHook(dir: string, extraEnv: NodeJS.ProcessEnv = {}) { + const script = join(dir, 'codex-hook.sh') + writeFileSync(script, codexInternals.getManagedScript('posix')) + chmodSync(script, 0o755) + const clean: NodeJS.ProcessEnv = {} + for (const [k, v] of Object.entries(process.env)) { + if (!k.startsWith('ORCA_')) { + clean[k] = v + } + } + return spawnSync('/bin/sh', [script], { + input: '{"hook_event_name":"SubagentStop","agent_id":"child"}\n', + env: { ...clean, ...extraEnv }, + timeout: 5000, + encoding: 'utf8' + }) +} + +describe('managed hook outside an Orca terminal', () => { + it('no Orca env at all: silent, exit 0, writes nothing', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-')) + const res = runHook(dir) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + expect(readdirSync(dir)).toEqual(['codex-hook.sh']) + }) + + it('pane key present but no endpoint: still silent and writes nothing', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-partial-')) + const res = runHook(dir, { ORCA_PANE_KEY: 'tab:0', ORCA_TAB_ID: 'tab' }) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + expect(readdirSync(dir)).toEqual(['codex-hook.sh']) + }) + + it('endpoint points at a path that does not exist: silent, exit 0', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-stale-')) + const res = runHook(dir, { + ORCA_AGENT_HOOK_ENDPOINT: join(dir, 'gone', 'deeper', 'endpoint.env'), + ORCA_PANE_KEY: 'tab:0' + }) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + // a stale env var must not create a spool tree for an Orca that is not installed here + expect(readdirSync(dir)).toEqual(['codex-hook.sh']) + }) + + it('readable endpoint without a pane key: silent, writes nothing', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-readable-')) + const endpoint = join(dir, 'endpoint.env') + writeFileSync(endpoint, 'ORCA_AGENT_HOOK_PORT=9\nORCA_AGENT_HOOK_TOKEN=stale\n') + const res = runHook(dir, { ORCA_AGENT_HOOK_ENDPOINT: endpoint }) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + expect(readdirSync(dir).sort()).toEqual(['codex-hook.sh', 'endpoint.env']) + }) +}) + +describe('antigravity out-of-band event name', () => { + it('records hookEventName and filters tool progress on it', () => { + const lines = buildPosixHookSpoolLines('antigravity', 'ORCA_ANTIGRAVITY_EVENT').join('\n') + expect(lines).toContain('"hookEventName":"%s"') + expect(lines).toContain('${ORCA_ANTIGRAVITY_EVENT:-}') + expect(lines).toContain('in PreToolUse|PostToolUse|PostToolUseFailure) return 0') + // payload-based filtering stays the default for every other provider + expect(buildPosixHookSpoolLines('codex').join('\n')).toContain('case "$payload" in') + }) +}) diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index fb7b3d85487..acba7927650 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -22,6 +22,52 @@ export function buildPosixHookPayloadCapture( ] } +/** Shell-side durable fallback shared by every POSIX managed hook. + * `eventNameVar` is for providers that send the event name out-of-band rather than in the + * payload JSON; without it both the progress filter and replay would miss the event name. */ +export function buildPosixHookSpoolLines(source: string, eventNameVar?: string): string[] { + // Why: the event name must be a printf ARG, not inlined in the single-quoted format, + // where a command substitution would be emitted literally. + const eventFormat = eventNameVar ? '"hookEventName":"%s",' : '' + const eventArg = eventNameVar ? ` "$(spool_json_escape "\${${eventNameVar}:-}")"` : '' + const spoolRecordLine = " { printf '\\n{".concat( + eventFormat, + '"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","launchToken":"%s","source":"%s","receivedAt":%s,"payload":%s}\\n\'', + eventArg, + ' "$(spool_json_escape "${ORCA_PANE_KEY:-}")" "$(spool_json_escape "${ORCA_TAB_ID:-}")" "$(spool_json_escape "${ORCA_WORKTREE_ID:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_ENV:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_VERSION:-}")" "$(spool_json_escape "${ORCA_AGENT_LAUNCH_TOKEN:-}")" "$(spool_json_escape "', + source, + '")" "$spool_now" "$payload"; } >> "$spool_file" 2>/dev/null || :' + ) + return [ + 'spool_hook_event() {', + eventNameVar + ? ` case "\${${eventNameVar}:-}" in PreToolUse|PostToolUse|PostToolUseFailure) return 0 ;; esac` + : ' case "$payload" in *\'"PreToolUse"\'*|*\'"PostToolUse"\'*|*\'"PostToolUseFailure"\'*) return 0 ;; esac', + ' [ -n "${ORCA_AGENT_HOOK_ENDPOINT:-}" ] || return 0', + // Why: an endpoint can linger in a parent shell after leaving Orca; without a pane key + // the record is un-attributable and would accumulate as pane-unknown.jsonl. + ' [ -n "${ORCA_PANE_KEY:-}" ] || return 0', + // Why: a stale env var must not create a spool tree for an Orca that is not installed here. + ' [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ] || return 0', + ' spool_base=${ORCA_AGENT_HOOK_ENDPOINT%/*}', + ' spool_dir="$spool_base/spool"', + ' mkdir -p "$spool_dir" 2>/dev/null || return 0', + ' chmod 700 "$spool_dir" 2>/dev/null || :', + " spool_id=$(printf %s \"${ORCA_PANE_KEY:-unknown}\" | tail -c 36 | tr '/:' '__')", + ' spool_file="$spool_dir/pane-$spool_id.jsonl"', + ' if [ -f "$spool_file" ] && find "$spool_file" -mtime +7 -print -quit 2>/dev/null | grep -q .; then : > "$spool_file"; fi', + ' [ -f "$spool_file" ] || : > "$spool_file"', + ' spool_size=$(wc -c < "$spool_file" 2>/dev/null || printf 0)', + ' [ "$spool_size" -lt 5242880 ] || return 0', + ' spool_now=$(date +%s 2>/dev/null || printf 0)', + ' spool_now=$((spool_now * 1000))', + ' spool_json_escape() { printf %s "$1" | sed \'s/\\\\/\\\\\\\\/g; s/"/\\\\"/g; s/[[:cntrl:]]/ /g\'; }', + spoolRecordLine, + ' chmod 600 "$spool_file" 2>/dev/null || :', + '}' + ] +} + export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin' // Why: qualify the stdin reader because Windows searches the worktree for // executables before PATH and hook payloads must not reach repo-local code. diff --git a/src/main/agent-hooks/server-relay-listener-replay.test.ts b/src/main/agent-hooks/server-relay-listener-replay.test.ts index 6c96d9c3bb8..0bd9b4b29ef 100644 --- a/src/main/agent-hooks/server-relay-listener-replay.test.ts +++ b/src/main/agent-hooks/server-relay-listener-replay.test.ts @@ -63,6 +63,7 @@ describe('AgentHookServer listener replay', () => { send(restartedRelay, { hook_event_name: 'SubagentStop', agent_id: 'child-a' }) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', + prompt: 'coordinate reviewers', model: 'gpt-5.4', providerSession: { key: 'session_id', id: 'root-session' }, subagents: [expect.objectContaining({ id: 'child-b' })] diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index e447be40262..ba29d3bc211 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -116,6 +116,12 @@ import { type AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary' +import { + buildSpoolHookBody, + drainAgentHookSpool, + launchTokenHash, + type SpoolRecord +} from '../../shared/agent-hook-spool' export type { AgentHookSource } @@ -801,6 +807,44 @@ export class AgentHookServer { } } + /** Replay is durable evidence from a prior runtime, not a live observation. */ + private withdrawReplayObservation(paneKey: string): void { + if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) { + this.notifyStatusChangeListeners() + } + } + + private ingestSpoolRecord(record: SpoolRecord): void { + if (!isAgentHookSource(record.source)) { + return + } + const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record)) + const normalized = this.normalizeLocalHookPayload(record.source, body) + if (!normalized.event) { + return + } + const replay = { ...normalized.event, isReplay: true as const } + const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, { + source: record.source, + hookEventName: replay.hookEventName, + isReplay: true, + hasExplicitPrompt: replay.hasExplicitPrompt, + launchToken: replay.launchToken + }) + if (statusDisposition === 'suppress') { + return + } + const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay + if (statusDisposition === 'restart') { + this.observations.rebind(event.paneKey) + } + this.recordCurrentAuthorityObservation(event) + this.applyNormalizedStatus(event, normalized.onAccepted) + if (event.payload.state !== 'done') { + this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) + } + } + setPaneStatusClearListener(listener: PaneStatusClearListener | null): void { this.onPaneStatusCleared = listener } @@ -2259,14 +2303,14 @@ export class AgentHookServer { claudeRunningNonAgentTask?: unknown payload: unknown }, - connectionId: string + connectionId: string | null ): void { // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. - if (typeof connectionId !== 'string') { + if (connectionId !== null && typeof connectionId !== 'string') { return } - const trimmedConnectionId = connectionId.trim() - if (trimmedConnectionId.length === 0) { + const trimmedConnectionId = connectionId?.trim() ?? null + if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) { return } if (!envelope || typeof envelope.paneKey !== 'string') { @@ -2286,6 +2330,14 @@ export class AgentHookServer { if (!parsedPaneKey) { return } + // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. + if (envelope.isReplay === true) { + const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + const actualLaunchTokenHash = launchTokenHash(envelope.launchToken) + if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) { + return + } + } if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { return } @@ -2515,6 +2567,15 @@ export class AgentHookServer { this.hydrateLastStatusFromDisk() } this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) + }) + } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { res.writeHead(404) @@ -3309,6 +3370,8 @@ export class AgentHookServer { restoredUnconfirmed: _restoredUnconfirmed, // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). observation: _observation, + // Replay provenance is runtime-only and must not survive another restart. + isReplay: _isReplay, launchToken, ...persistedPayload } = enrichedPayload diff --git a/src/main/agent-hooks/spool.test.ts b/src/main/agent-hooks/spool.test.ts new file mode 100644 index 00000000000..3e460fdc3ba --- /dev/null +++ b/src/main/agent-hooks/spool.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from 'vitest' +import { + appendFileSync, + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + statSync, + writeFileSync +} from 'node:fs' +import { execFileSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + AGENT_HOOK_SPOOL_MAX_FILES, + drainAgentHookSpool, + launchTokenHash, + readSpoolRecords, + type SpoolRecord +} from '../../shared/agent-hook-spool' +import { AgentHookServer, _internals } from './server' +import { buildBody } from './server.test-fixtures' +import { _internals as codexInternals } from '../codex/hook-service' +import { makePaneKey } from '../../shared/stable-pane-id' +import { buildPosixHookSpoolLines } from './hook-stdin-contract' + +describe('agent hook spool', () => { + it('appends each record with one printf write to prevent concurrent field interleaving', () => { + const spoolLine = buildPosixHookSpoolLines('codex').find((line) => + line.includes('>> "$spool_file"') + ) + expect(spoolLine).toBeDefined() + expect(spoolLine!.match(/printf/g)).toHaveLength(1) + expect(spoolLine).toContain('"$spool_now" "$payload"') + }) + + it('drops torn lines while retaining complete records', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-')) + const file = join(dir, 'pane.jsonl') + writeFileSync( + file, + '\n{"paneKey":"tab:1","source":"codex","receivedAt":1,"payload":{}}\n{"paneKey":' + ) + expect(readSpoolRecords(file, 1)).toHaveLength(1) + }) + + it('waits for a newline before replaying a complete-looking final record', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-unterminated-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + const file = join(spool, 'pane-live.jsonl') + const record = JSON.stringify({ + paneKey: 'tab:live', + source: 'codex', + receivedAt: Date.now(), + payload: { state: 'done' } + }) + writeFileSync(file, record) + const ingested: SpoolRecord[] = [] + const options = { + endpointDir: dir, + getPersistedLaunchTokenHash: () => undefined, + ingest: (value: SpoolRecord) => ingested.push(value) + } + expect(readSpoolRecords(file)).toHaveLength(0) + expect(drainAgentHookSpool(options)).toBe(0) + expect(readFileSync(file, 'utf8')).toBe(record) + + appendFileSync(file, '\n') + expect(drainAgentHookSpool(options)).toBe(1) + expect(ingested).toHaveLength(1) + expect(readFileSync(file)).toHaveLength(0) + }) + + it('does not let historical empty pane files starve newer records', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-empty-files-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + for (let index = 0; index < AGENT_HOOK_SPOOL_MAX_FILES; index += 1) { + writeFileSync(join(spool, `pane-empty-${index}.jsonl`), '') + } + const live = join(spool, 'pane-live.jsonl') + writeFileSync( + live, + `\n${JSON.stringify({ paneKey: 'tab:live', source: 'codex', receivedAt: Date.now(), payload: { state: 'done' } })}\n` + ) + const ingested: SpoolRecord[] = [] + drainAgentHookSpool({ + endpointDir: dir, + getPersistedLaunchTokenHash: () => undefined, + ingest: (record) => ingested.push(record) + }) + expect(ingested).toHaveLength(1) + expect(ingested[0]?.paneKey).toBe('tab:live') + }) + + it('rejects stale launch tokens before ingest and truncates in place', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + const file = join(spool, 'pane-1.jsonl') + writeFileSync( + file, + `\n${JSON.stringify({ paneKey: 'tab:1', source: 'codex', launchToken: 'old', receivedAt: Date.now(), payload: { state: 'done' } })}\n` + ) + const inode = statSync(file).ino + const ingested: unknown[] = [] + drainAgentHookSpool({ + endpointDir: dir, + getPersistedLaunchTokenHash: () => launchTokenHash('new')!, + ingest: (record) => ingested.push(record) + }) + expect(ingested).toHaveLength(0) + expect(readFileSync(file)).toHaveLength(0) + expect(statSync(file).ino).toBe(inode) + }) + + it('ingests a record with the matching launch token', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + const file = join(spool, 'pane-1.jsonl') + writeFileSync( + file, + `\n${JSON.stringify({ paneKey: 'tab:1', source: 'codex', launchToken: 'same', receivedAt: Date.now(), payload: { state: 'done' } })}\n` + ) + const ingested: unknown[] = [] + drainAgentHookSpool({ + endpointDir: dir, + getPersistedLaunchTokenHash: () => launchTokenHash('same')!, + ingest: (record) => ingested.push(record) + }) + expect(ingested).toHaveLength(1) + expect(ingested[0]).toMatchObject({ paneKey: 'tab:1', isReplay: true }) + }) + + it('replays a spooled Codex SubagentStop through the server after restart', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-e2e-')) + const paneKey = makePaneKey('tab-spool', '00000000-0000-4000-8000-000000000001') + const launchToken = 'generation-token' + const first = new AgentHookServer() + await first.start({ env: 'production', userDataPath }) + const started = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SubagentStart', agent_id: 'child-spooled' }), + 'production' + )! + first.ingestRemote( + { + paneKey, + source: 'codex', + hookEventName: 'SubagentStart', + launchToken, + payload: started.payload + }, + 'spool-test' + ) + expect(first.getStatusSnapshot()).toHaveLength(1) + first.flushStatusPersistSync() + first.stop() + const spoolDir = join(userDataPath, 'agent-hooks', 'spool') + mkdirSync(spoolDir, { recursive: true }) + writeFileSync( + join(spoolDir, 'pane-tab-spooled_0.jsonl'), + `\n${JSON.stringify({ paneKey, source: 'codex', hookEventName: 'SubagentStop', launchToken, receivedAt: Date.now(), payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' } })}\n` + ) + const restarted = new AgentHookServer() + await restarted.start({ env: 'production', userDataPath }) + try { + const snapshot = restarted.getStatusSnapshot() + expect(snapshot).toHaveLength(1) + expect(snapshot[0]!.subagents).toBeUndefined() + restarted.flushStatusPersistSync() + expect(readFileSync(restarted.lastStatusPath!, 'utf8')).not.toContain('isReplay') + } finally { + restarted.stop() + } + }) + + it('rejects a stale remote replay against the hydrated launch-token fence', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-remote-fence-')) + const paneKey = makePaneKey('tab-remote-fence', '00000000-0000-4000-8000-000000000003') + const first = new AgentHookServer() + const second = new AgentHookServer() + try { + await first.start({ env: 'production', userDataPath }) + first.ingestRemote( + { + paneKey, + source: 'codex', + launchToken: 'old-generation', + payload: { state: 'working', agentType: 'codex', prompt: 'old' } + }, + 'ssh-1' + ) + first.flushStatusPersistSync() + first.stop() + + await second.start({ env: 'production', userDataPath }) + second.ingestRemote( + { + paneKey, + source: 'codex', + launchToken: 'new-generation', + isReplay: true, + payload: { state: 'done', agentType: 'codex', prompt: 'stale completion' } + }, + 'ssh-2' + ) + expect(second.getStatusSnapshot()[0]).toMatchObject({ + paneKey, + state: 'working', + prompt: 'old' + }) + } finally { + first.stop() + second.stop() + } + }) + + it('spools when the endpoint is present but the receiver is unavailable', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-failure-')) + const endpointDir = join(dir, 'agent-hooks') + mkdirSync(endpointDir, { recursive: true }) + const endpoint = join(endpointDir, 'endpoint.env') + writeFileSync( + endpoint, + 'ORCA_AGENT_HOOK_PORT=9\nORCA_AGENT_HOOK_TOKEN=stale\nORCA_AGENT_HOOK_ENV=production\nORCA_AGENT_HOOK_VERSION=1\n' + ) + const script = join(dir, 'codex-hook.sh') + writeFileSync(script, codexInternals.getManagedScript('posix')) + chmodSync(script, 0o755) + execFileSync('/bin/sh', [script], { + input: '{"hook_event_name":"SubagentStop","agent_id":"child"}\n', + env: { + ...process.env, + ORCA_AGENT_HOOK_ENDPOINT: endpoint, + ORCA_PANE_KEY: 'tab-failure:0', + ORCA_TAB_ID: 'tab-failure', + ORCA_AGENT_LAUNCH_TOKEN: 'generation-token' + }, + timeout: 5000 + }) + const spoolFiles = readdirSync(join(endpointDir, 'spool')) + expect(spoolFiles).toHaveLength(1) + expect(readFileSync(join(endpointDir, 'spool', spoolFiles[0]!), 'utf8')).toContain( + 'SubagentStop' + ) + }) + + it('does not mark a non-terminal downtime replay as runtime-observed', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-observed-')) + const paneKey = makePaneKey('tab-observed', '00000000-0000-4000-8000-000000000002') + const launchToken = 'observed-generation' + const first = new AgentHookServer() + await first.start({ env: 'production', userDataPath }) + const started = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SubagentStart', agent_id: 'child-observed' }), + 'production' + )! + first.ingestRemote( + { + paneKey, + source: 'codex', + hookEventName: 'SubagentStart', + launchToken, + payload: started.payload + }, + null + ) + first.flushStatusPersistSync() + first.stop() + const spoolDir = join(userDataPath, 'agent-hooks', 'spool') + mkdirSync(spoolDir, { recursive: true }) + writeFileSync( + join(spoolDir, 'pane-observed.jsonl'), + `\n${JSON.stringify({ paneKey, source: 'codex', hookEventName: 'SubagentStart', launchToken, receivedAt: Date.now(), payload: started.payload })}\n` + ) + const restarted = new AgentHookServer() + await restarted.start({ env: 'production', userDataPath }) + try { + expect(restarted.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false) + } finally { + restarted.stop() + } + }) +}) diff --git a/src/main/antigravity/hook-script.ts b/src/main/antigravity/hook-script.ts index 0645416c005..67f1f639ef9 100644 --- a/src/main/antigravity/hook-script.ts +++ b/src/main/antigravity/hook-script.ts @@ -1,5 +1,6 @@ import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, WINDOWS_HOOK_STDIN_DRAIN_COMMAND @@ -55,10 +56,12 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: some Antigravity events arrive without stdin but still need a // status post, so the shared capture maps empty input to an object. ...buildPosixHookPayloadCapture('empty-object'), + ...buildPosixHookSpoolLines('antigravity', 'ORCA_ANTIGRAVITY_EVENT'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Timeout caps best-effort hook posts if the local listener stalls. @@ -76,7 +79,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', ' --data-urlencode "hook_event_name=${ORCA_ANTIGRAVITY_EVENT}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 4de81077ab2..3dba1121a20 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -17,6 +17,7 @@ import { import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, WINDOWS_HOOK_STDIN_DRAIN_LABEL @@ -96,6 +97,7 @@ function getManagedScript( // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('claude'), ...(options.skipWhenDevinImportsClaude ? [ // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. @@ -115,6 +117,7 @@ function getManagedScript( ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: post form fields because path-bearing payloads are unsafe in hand-built JSON. @@ -129,7 +132,7 @@ function getManagedScript( ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 6dc78fdabf1..a25f14bcd89 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -31,6 +31,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, POSIX_HOOK_STDIN_DRAIN_COMMAND @@ -818,6 +819,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('codex'), // Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts). 'load_hook_endpoint() {', ' endpoint_path="$1"', @@ -844,6 +846,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' load_hook_endpoint "$ORCA_AGENT_HOOK_ENDPOINT"', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', 'post_codex_hook() {', @@ -875,9 +878,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if is_wsl_runtime; then', ' windows_curl=$(command -v curl.exe 2>/dev/null || true)', ' if [ -n "$windows_curl" ] && [ -x "$windows_curl" ]; then', - ' post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true', + ' if post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1; then', + ' exit 0', + ' fi', + ' # post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true', ' fi', 'fi', + 'spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/command-code/command-code-managed-script.ts b/src/main/command-code/command-code-managed-script.ts index ca45f2fa579..59ea79b9d3a 100644 --- a/src/main/command-code/command-code-managed-script.ts +++ b/src/main/command-code/command-code-managed-script.ts @@ -1,6 +1,7 @@ import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -36,6 +37,7 @@ export function buildCommandCodeManagedScript( return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('command-code'), '__orca_read_ancestor_var() {', ' __orca_name="$1"', ' __orca_pid="${PPID:-}"', @@ -119,6 +121,7 @@ export function buildCommandCodeManagedScript( ' done', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Timeout caps best-effort hook posts if the local listener stalls. @@ -135,7 +138,7 @@ export function buildCommandCodeManagedScript( ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/copilot/copilot-managed-script.ts b/src/main/copilot/copilot-managed-script.ts index 83b40e51d1b..492caafc045 100644 --- a/src/main/copilot/copilot-managed-script.ts +++ b/src/main/copilot/copilot-managed-script.ts @@ -1,5 +1,8 @@ import { getSharedManagedScriptPath } from '../agent-hooks/installer-utils' -import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines +} from '../agent-hooks/hook-stdin-contract' export function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'copilot-hook.ps1' : 'copilot-hook.sh' @@ -53,12 +56,14 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { '#!/bin/sh', "printf '{}\\n'", ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('copilot'), // Why: Copilot consumes stdout for some hooks, so stdout is emitted before // endpoint refresh, stdin parsing, or the network POST can fail. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline @@ -75,7 +80,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index ab9d07b25eb..310afad939c 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -23,6 +23,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -76,11 +77,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('cursor'), // Why: refresh endpoint coordinates so surviving PTYs keep reporting. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: post form fields because path-bearing worktree IDs are unsafe in hand-built JSON. @@ -95,7 +98,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index 6c485b0daf5..a5e3667551e 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -13,6 +13,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -55,12 +56,14 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('devin'), // Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env). // Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race or CRLF-mangled line can't leak shell parse errors into agent transcripts (fail-open). 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: worktreeId embeds a filesystem path, so hand-building JSON in shell is unsafe (quotes/newlines); post as form fields instead. @@ -75,7 +78,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index fdef56a2401..3bdb715427f 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -24,6 +24,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -94,10 +95,12 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('droid'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Timeout caps best-effort hook posts if the local listener stalls. @@ -114,7 +117,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 72ee056bd5f..6fd529e96e4 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -24,6 +24,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -72,11 +73,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: emit `{}` first so Gemini never stalls parsing stdout, even if the guards below exit early. 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('gemini'), // Why: source refreshes endpoint coords so a PTY surviving an Orca restart keeps reporting. See claude/hook-service.ts. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: worktreeId embeds a path, so post form fields, not hand-built JSON that breaks on quotes/newlines. @@ -91,7 +94,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/grok/grok-hook-script.ts b/src/main/grok/grok-hook-script.ts index cf4c57580b9..997bf7e64d4 100644 --- a/src/main/grok/grok-hook-script.ts +++ b/src/main/grok/grok-hook-script.ts @@ -3,7 +3,10 @@ import { wrapPosixHookCommand, wrapWindowsCmdHookCommand } from '../agent-hooks/installer-utils' -import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines +} from '../agent-hooks/hook-stdin-contract' import { buildWindowsGrokHookScript, GROK_HOME_ENVELOPE_MAX_LENGTH @@ -33,10 +36,12 @@ export function getGrokManagedScript(target: 'local' | 'posix' = 'local'): strin return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('grok'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', 'grok_home=', @@ -54,7 +59,7 @@ export function getGrokManagedScript(target: 'local' | 'posix' = 'local'): strin ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', ' --data-urlencode "grokHome=${grok_home}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index a7b954f01cb..e397fa0b026 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -24,7 +24,10 @@ import { writeManagedScriptRemote, writeTextFileRemoteAtomic } from '../agent-hooks/installer-utils-remote' -import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines +} from '../agent-hooks/hook-stdin-contract' import { applyManagedKimiHooks, KIMI_HOOK_EVENTS, @@ -71,14 +74,25 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + // Why: the windows-local ordering runs this guard before stdin is read and before + // spool_hook_event is defined, so only the payload-first ordering may spool here. + ...(windowsLocal ? [] : [' spool_hook_event']), ' exit 0', 'fi' ] return [ '#!/bin/sh', ...(windowsLocal - ? [...endpointRefreshAndGuard, ...buildPosixHookPayloadCapture()] - : [...buildPosixHookPayloadCapture(), ...endpointRefreshAndGuard]), + ? [ + ...endpointRefreshAndGuard, + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('kimi') + ] + : [ + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('kimi'), + ...endpointRefreshAndGuard + ]), // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. @@ -95,7 +109,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 0e3771aff55..e247706b5dc 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { RelayAgentHookServer } from './agent-hook-server' @@ -82,6 +82,79 @@ describe('RelayAgentHookServer', () => { } }) + it('normalizes and forwards raw spooled hooks on startup', async () => { + const spoolDir = join(dir, 'spool') + const spoolFile = join(spoolDir, 'pane-codex.jsonl') + mkdirSync(spoolDir) + writeFileSync( + spoolFile, + `${JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'remote', + version: '1', + launchToken: 'generation-token', + hookEventName: 'SubagentStop', + source: 'codex', + payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' }, + receivedAt: Date.now() + })}\n` + ) + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + + await server.start() + try { + expect(forward).toHaveBeenCalledTimes(1) + expect(forward.mock.calls[0][0]).toMatchObject({ + source: 'codex', + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + launchToken: 'generation-token', + hookEventName: 'SubagentStop', + isReplay: true, + env: 'remote', + version: '1', + payload: { state: 'working', agentType: 'codex' } + }) + expect(readFileSync(spoolFile)).toHaveLength(0) + } finally { + server.stop() + } + }) + + it('keeps the relay listening when spool replay forwarding fails', async () => { + const spoolDir = join(dir, 'spool') + const spoolFile = join(spoolDir, 'pane-codex.jsonl') + mkdirSync(spoolDir) + writeFileSync( + spoolFile, + `${JSON.stringify({ + paneKey: PANE_KEY, + source: 'codex', + hookEventName: 'SubagentStop', + payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' }, + receivedAt: Date.now() + })}\n` + ) + const server = new RelayAgentHookServer({ + endpointDir: dir, + forward: () => { + throw new Error('receiver unavailable') + } + }) + + try { + await expect(server.start()).resolves.toBeUndefined() + expect(server.getCoordinates().port).toBeGreaterThan(0) + expect(readFileSync(spoolFile, 'utf8')).not.toBe('') + } finally { + server.stop() + } + }) + it('caches before forwarding, schedules retries after forwarding, and responds last', async () => { const order: string[] = [] let server!: RelayAgentHookServer diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 84474cbab00..ec980470dd4 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -24,10 +24,16 @@ import { isHookRequestTruncatedError } from '../shared/agent-hook-transport-interference' import { + isAgentHookSource, REMOTE_AGENT_HOOK_ENV, type AgentHookRelayEnvelope, type AgentHookSource } from '../shared/agent-hook-relay' +import { + buildSpoolHookBody, + drainAgentHookSpool, + type SpoolRecord +} from '../shared/agent-hook-spool' import { buildRelayHookPtyEnv, defaultEndpointDir } from './agent-hook-endpoint-coordinates' import { buildRelayHookEnvelope, hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build' import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' @@ -100,6 +106,19 @@ export class RelayAgentHookServer { this.token = this.fixedToken ?? randomUUID() this.endpointFileWritten = false this.portFallbackApplied = false + try { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: () => undefined, + ingest: (record) => this.ingestSpoolRecord(record) + }) + } catch (err) { + // Why: a downstream relay failure must not prevent the loopback listener from starting; + // the untruncated spool file remains available for retry on the next restart. + process.stderr.write( + `[relay-hook-server] spool replay failed: ${err instanceof Error ? err.message : String(err)}\n` + ) + } try { await this.listenOn(this.preferredPort) } catch (err) { @@ -273,7 +292,8 @@ export class RelayAgentHookServer { event: AgentHookEventPayload, source: AgentHookSource, env?: string, - version?: string + version?: string, + options: { isReplay?: boolean } = {} ): void { if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { this.retryScheduler.clearAssistantMessageRetry(event.paneKey) @@ -294,6 +314,22 @@ export class RelayAgentHookServer { } this.clearPaneState(oldest) } - this.forward(buildRelayHookEnvelope(event, source, env, version)) + this.forward(buildRelayHookEnvelope(event, source, env, version, options)) + } + + private ingestSpoolRecord(record: SpoolRecord): void { + if (!isAgentHookSource(record.source)) { + return + } + const body = buildSpoolHookBody(record) + const event = normalizeHookPayload(this.state, record.source, body, this.env, { + deferCompactOwnershipToClient: true + }) + if (!event) { + return + } + this.applyEvent(event, record.source, hookBodyEnv(body), hookBodyVersion(body), { + isReplay: true + }) } } diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index c14c1d8a2b1..1131008ca7a 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -137,8 +137,15 @@ export function reconcileRemoteCodexState( if (!lead) { return payload } + // Child lifecycle hooks commonly omit the root prompt. Preserve the last known + // turn label while merging their roster/state so relay restarts do not blank it. + const prompt = + agentId && payload.prompt.length === 0 && previous?.agentType === 'codex' + ? previous.prompt + : payload.prompt return { ...payload, + prompt, state: codexRosterEffectiveState(roster, lead.state), model: lead.model ?? payload.model, subagents: codexRosterToSnapshots(roster) diff --git a/src/shared/agent-hook-spool.ts b/src/shared/agent-hook-spool.ts new file mode 100644 index 00000000000..3ad478ed8a5 --- /dev/null +++ b/src/shared/agent-hook-spool.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto' +import { + closeSync, + fstatSync, + ftruncateSync, + openSync, + readFileSync, + readSync, + readdirSync, + statSync, + writeSync +} from 'node:fs' +import { join } from 'node:path' + +export const AGENT_HOOK_SPOOL_MAX_BYTES = 5 * 1024 * 1024 +export const AGENT_HOOK_SPOOL_MAX_FILES = 1024 +export const AGENT_HOOK_SPOOL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + +export type SpoolRecord = { + paneKey: string + tabId?: string + worktreeId?: string + env?: string + version?: string + launchToken?: string + hookEventName?: string + source: string + payload: unknown + receivedAt: number + [key: string]: unknown +} + +/** Restore the HTTP listener body shape from a shell-written spool record. */ +export function buildSpoolHookBody(record: SpoolRecord): Record { + return { + paneKey: record.paneKey, + tabId: record.tabId, + worktreeId: record.worktreeId, + env: record.env, + version: record.version, + launchToken: record.launchToken, + hookEventName: record.hookEventName, + payload: record.payload + } +} + +export function launchTokenHash(token: string | undefined): string | null { + return token?.trim() ? createHash('sha256').update(token.trim()).digest('hex') : null +} + +export function readSpoolRecords(path: string, now = Date.now()): SpoolRecord[] { + return readSpoolFile(path, now).records +} + +/** Records plus the byte offset through the last COMPLETE line. A torn trailing line is + * left unconsumed so a writer still finishing it is not truncated away. */ +export function readSpoolFile( + path: string, + now = Date.now() +): { records: SpoolRecord[]; consumed: number } { + let bytes: Buffer + try { + bytes = readFileSync(path) + } catch { + return { records: [], consumed: 0 } + } + const records: SpoolRecord[] = [] + let consumed = 0 + let start = 0 + for (let end = 0; end <= bytes.length; end += 1) { + if (end !== bytes.length && bytes[end] !== 0x0a) { + continue + } + // A final line without its newline may still be in flight from a hook writer. + // Leave it untouched until the writer terminates the record explicitly. + if (end === bytes.length && (end === 0 || bytes[end - 1] !== 0x0a)) { + break + } + const lineBytes = bytes.subarray(start, end) + if (end !== bytes.length) { + consumed = end + 1 + } + start = end + 1 + if (lineBytes.length === 0) { + continue + } + try { + const value = JSON.parse(lineBytes.toString('utf8')) as Partial + if ( + typeof value.paneKey === 'string' && + typeof value.source === 'string' && + value.payload !== undefined && + typeof value.receivedAt === 'number' && + value.receivedAt >= now - AGENT_HOOK_SPOOL_MAX_AGE_MS + ) { + records.push(value as SpoolRecord) + } + } catch { + // Torn lines are discarded while later complete lines remain replayable. + } + } + return { records, consumed } +} + +export type SpoolDrainOptions = { + endpointDir: string + getPersistedLaunchTokenHash: (paneKey: string) => string | undefined + ingest: (record: SpoolRecord) => void + now?: number +} + +/** Drain JSONL files in place; never replace or unlink an inode held by a hook writer. */ +export function drainAgentHookSpool(options: SpoolDrainOptions): number { + const spoolDir = join(options.endpointDir, 'spool') + let names: string[] + try { + names = readdirSync(spoolDir) + } catch { + return 0 + } + const now = options.now ?? Date.now() + const candidates = names + .map((name) => { + const path = join(spoolDir, name) + try { + const stat = statSync(path) + // Empty files are retained to keep append handles race-safe, but they must not + // consume the bounded replay candidate set ahead of files with durable events. + return stat.isFile() && stat.size > 0 ? { path, mtimeMs: stat.mtimeMs } : null + } catch { + return null + } + }) + .filter((entry): entry is { path: string; mtimeMs: number } => entry !== null) + .sort((a, b) => a.mtimeMs - b.mtimeMs) + .slice(0, AGENT_HOOK_SPOOL_MAX_FILES) + let drained = 0 + for (const candidate of candidates) { + const { records, consumed } = readSpoolFile(candidate.path, now) + for (const record of records) { + const expected = options.getPersistedLaunchTokenHash(record.paneKey) + const actual = launchTokenHash(record.launchToken) + if (expected && actual !== expected) { + continue + } + options.ingest({ ...record, isReplay: true }) + drained += 1 + } + try { + const fd = openSync(candidate.path, 'r+') + try { + // Keep the inode so a concurrent append handle cannot silently orphan writes. + const size = fstatSync(fd).size + if (size > consumed) { + // Why: a hook may have appended between the read and here; shift the unread tail + // to the front instead of truncating to zero, which would erase it. + const tail = Buffer.alloc(size - consumed) + readSync(fd, tail, 0, tail.length, consumed) + writeSync(fd, tail, 0, tail.length, 0) + ftruncateSync(fd, tail.length) + } else { + ftruncateSync(fd, 0) + } + } finally { + closeSync(fd) + } + } catch { + // A concurrently removed or inaccessible file is harmless; the next launch retries it. + } + } + return drained +}