diff --git a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs index 3011e6fc123..5cda2b640e0 100644 --- a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs +++ b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs @@ -147,12 +147,16 @@ function readGeneratedScripts(home, minMtime) { guardIndex === -1 || captureIndex === -1 || captureIndex <= guardIndex || - !body.includes('kill -9 "$_orca_cat"') + !body.includes('if command -p cat /dev/null 2>&1; then') || + !body.includes('command -p cat <&3 2>/dev/null &') || + !body.includes('command cat <&3 2>/dev/null &') || + !body.includes('kill -9 "$_orca_reader"') || + !body.includes('wait "$_orca_reader" 2>/dev/null || :') ) { throw new Error([fileName, ' is missing the bounded stdin watchdog'].join('')) } } else { - const captureIndex = body.indexOf('payload=$(cat)') + const captureIndex = body.indexOf('payload=$({ command -p cat 2>/dev/null || cat; })') const firstExitIndex = body.indexOf('exit 0') if (captureIndex === -1 || firstExitIndex <= captureIndex) { throw new Error([fileName, ' can exit before capturing stdin'].join('')) @@ -216,7 +220,7 @@ async function verifyNoOpWrites(scripts, home, payload) { : (process.env.PATH ?? '/usr/bin:/bin') const result = await runShell( ['/bin/sh ', JSON.stringify(script.path)].join(''), - payload, + script.fileName === 'antigravity-hook.sh' ? '' : payload, withoutOrcaEnvironment({ HOME: home, PATH: path, diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index fb7b3d85487..4c8fb7e08a7 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -4,7 +4,8 @@ export type PosixHookEmptyPayloadPolicy = 'exit' | 'empty-object' // sees exit 127 and a broken pipe mid-write (#8110). `command -p` resolves from // the shell's built-in default PATH, so it also survives hosts without /bin/cat // (NixOS) and ignores a worktree-local `cat` that could capture the payload. -export const POSIX_HOOK_STDIN_READER = '{ command -p cat 2>/dev/null || cat; }' +export const POSIX_HOOK_STDIN_READER_COMMAND = 'command -p cat' +export const POSIX_HOOK_STDIN_READER = `{ ${POSIX_HOOK_STDIN_READER_COMMAND} 2>/dev/null || cat; }` export const POSIX_HOOK_STDIN_DRAIN_COMMAND = `${POSIX_HOOK_STDIN_READER} >/dev/null 2>&1 || :` // Why: every POSIX hook must own stdin before any no-op exit; sharing this diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index ddade5c22ea..a57d26e9606 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -62,7 +62,7 @@ import { GrokHookService } from '../grok/hook-service' import { KimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils' -import { POSIX_HOOK_STDIN_READER } from './hook-stdin-contract' +import { POSIX_HOOK_STDIN_READER, POSIX_HOOK_STDIN_READER_COMMAND } from './hook-stdin-contract' import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command' import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture' import { findGitBash } from './windows-git-bash-path.test-fixture' @@ -479,7 +479,16 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () const captureIndex = script.indexOf('exec 3<&0') expect(guardIndex, `${agent} env guard`).toBeGreaterThanOrEqual(0) expect(captureIndex, `${agent} bounded capture`).toBeGreaterThan(guardIndex) - expect(script, `${agent} watchdog`).toContain('kill -9 "$_orca_cat"') + expect(script, `${agent} reader probe`).toContain( + `if ${POSIX_HOOK_STDIN_READER_COMMAND} /dev/null 2>&1; then` + ) + expect(script, `${agent} qualified reader`).toContain( + `${POSIX_HOOK_STDIN_READER_COMMAND} <&3 2>/dev/null &` + ) + expect(script, `${agent} fallback reader`).toContain('command cat <&3 2>/dev/null &') + expect(script, `${agent} grouped reader`).not.toContain(`${POSIX_HOOK_STDIN_READER} <&3 &`) + expect(script, `${agent} watchdog`).toContain('kill -9 "$_orca_reader"') + expect(script, `${agent} reader wait`).toContain('wait "$_orca_reader" 2>/dev/null || :') continue } const captureIndex = script.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`) diff --git a/src/main/antigravity/hook-script.ts b/src/main/antigravity/hook-script.ts index 4459274e72f..2b2ef484abd 100644 --- a/src/main/antigravity/hook-script.ts +++ b/src/main/antigravity/hook-script.ts @@ -1,7 +1,7 @@ import { buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, - POSIX_HOOK_STDIN_READER, + POSIX_HOOK_STDIN_READER_COMMAND, WINDOWS_HOOK_STDIN_DRAIN_COMMAND } from '../agent-hooks/hook-stdin-contract' import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils' @@ -68,13 +68,17 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Detach the sleeper from this capture so closed-stdin events do not wait the bound. 'exec 3<&0', 'payload=$(', - ` ${POSIX_HOOK_STDIN_READER} <&3 &`, - ' _orca_cat=$!', - ` ( command -p sleep ${ANTIGRAVITY_POSIX_STDIN_WATCHDOG_SECONDS} && kill -9 "$_orca_cat" 2>/dev/null ) >/dev/null 2>&1 &`, + ` if ${POSIX_HOOK_STDIN_READER_COMMAND} /dev/null 2>&1; then`, + ` ${POSIX_HOOK_STDIN_READER_COMMAND} <&3 2>/dev/null &`, + ' else', + ' command cat <&3 2>/dev/null &', + ' fi', + ' _orca_reader=$!', + ` ( command -p sleep ${ANTIGRAVITY_POSIX_STDIN_WATCHDOG_SECONDS} && kill -9 "$_orca_reader" 2>/dev/null ) >/dev/null 2>&1 &`, ' _orca_watch=$!', - ' wait "$_orca_cat"', + ' wait "$_orca_reader" 2>/dev/null || :', ' kill -9 "$_orca_watch" 2>/dev/null', - ' wait "$_orca_watch" 2>/dev/null', + ' wait "$_orca_watch" 2>/dev/null || :', ')', 'exec 3<&-', 'if [ -z "$payload" ]; then', diff --git a/src/main/antigravity/hook-service.test.ts b/src/main/antigravity/hook-service.test.ts index d82abae6c4b..c7fb2b01406 100644 --- a/src/main/antigravity/hook-service.test.ts +++ b/src/main/antigravity/hook-service.test.ts @@ -17,7 +17,10 @@ vi.mock('os', async () => { }) import { AntigravityHookService } from './hook-service' -import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' +import { + POSIX_HOOK_STDIN_READER, + POSIX_HOOK_STDIN_READER_COMMAND +} from '../agent-hooks/hook-stdin-contract' import { createManagedCommandMatcher } from '../agent-hooks/installer-utils' const ANTIGRAVITY_SCRIPT_FILE_NAME = @@ -106,11 +109,19 @@ describe('AntigravityHookService', () => { expect(script).toContain('setlocal DisableDelayedExpansion') } else { expect(script).toContain('hook_event_name=${ORCA_ANTIGRAVITY_EVENT}') - expect(script).toContain(`${POSIX_HOOK_STDIN_READER} <&3 &`) + expect(script).toContain( + `if ${POSIX_HOOK_STDIN_READER_COMMAND} /dev/null 2>&1; then` + ) + expect(script).toContain(`${POSIX_HOOK_STDIN_READER_COMMAND} <&3 2>/dev/null &`) + expect(script).toContain('command cat <&3 2>/dev/null &') + expect(script).not.toContain(`${POSIX_HOOK_STDIN_READER} <&3 &`) expect(script).toContain('command -p sleep') - expect(script).toContain('kill -9 "$_orca_cat"') + expect(script).toContain('kill -9 "$_orca_reader"') + expect(script).toContain('wait "$_orca_reader" 2>/dev/null || :') expect(script).toContain('exec 3<&0') - expect(script.indexOf('[ -z "$ORCA_PANE_KEY" ]')).toBeLessThan(script.indexOf('_orca_cat=$!')) + expect(script.indexOf('[ -z "$ORCA_PANE_KEY" ]')).toBeLessThan( + script.indexOf('_orca_reader=$!') + ) expect(script).toContain("payload='{}'") expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi') // Why: payload is piped to curl via stdin (`payload@-`) so it never lands diff --git a/src/main/antigravity/posix-hook-payload-delivery.test.ts b/src/main/antigravity/posix-hook-payload-delivery.test.ts index 40677122d92..414e04a04ab 100644 --- a/src/main/antigravity/posix-hook-payload-delivery.test.ts +++ b/src/main/antigravity/posix-hook-payload-delivery.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { spawn } from 'node:child_process' import { createServer, type Server } from 'node:http' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type * as osModule from 'node:os' @@ -38,7 +38,7 @@ const CLOSED_PAYLOAD = `${JSON.stringify({ toolCall: { name: 'run_command', args: { CommandLine: 'pwd' } }, transcriptPath: '/tmp/antigravity-transcript.jsonl' })}\n` -const ABANDONED_LINE_PAYLOAD = `${JSON.stringify({ fullyIdle: false })}\n` +const ABANDONED_LINE_PAYLOAD = `${JSON.stringify({ fullyIdle: false })} \n` type HookPost = { payload: string | null @@ -81,11 +81,13 @@ async function startHookListener(): Promise<{ type HookRun = { exitCode: number | null stdout: string + stderr: string timedOut: boolean elapsedMs: number } -const EXIT_BUDGET_MS = (ANTIGRAVITY_POSIX_STDIN_WATCHDOG_SECONDS + 2) * 1000 +// Includes curl's 1.5s bound and the listener settle delay. +const EXIT_BUDGET_MS = (ANTIGRAVITY_POSIX_STDIN_WATCHDOG_SECONDS + 3) * 1000 function runScript( scriptPath: string, @@ -97,11 +99,12 @@ function runScript( const startedAt = Date.now() const child = spawn('/bin/sh', [scriptPath], { stdio: ['pipe', 'pipe', 'pipe'], env }) let stdout = '' + let stderr = '' let timedOut = false const timer = setTimeout(() => { timedOut = true child.kill('SIGKILL') - }, EXIT_BUDGET_MS + 1000) + }, EXIT_BUDGET_MS) child.on('error', (error) => { clearTimeout(timer) reject(error) @@ -109,7 +112,9 @@ function runScript( child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) - child.stderr.on('data', () => {}) + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) child.on('close', (exitCode) => { clearTimeout(timer) setTimeout( @@ -117,6 +122,7 @@ function runScript( resolve({ exitCode, stdout, + stderr, timedOut, elapsedMs: Date.now() - startedAt }), @@ -206,17 +212,23 @@ describe.skipIf(process.platform === 'win32')('Antigravity POSIX hook payload de expect(result.exitCode).toBe(0) expect(result.elapsedMs).toBeLessThan(EXIT_BUDGET_MS) expect(result.stdout.trim()).toBe(expectedStdout(eventName)) - expect( - posts.some((post) => post.hookEventName === eventName && post.token === HOOK_TOKEN) - ).toBe(true) + expect(result.stderr).toBe('') + expect(posts).toEqual([{ payload: '{}', hookEventName: eventName, token: HOOK_TOKEN }]) } ) it('exits when a payload line is written but stdin is never closed', async () => { const { scriptPath, port, posts } = await installAndListen() + const endpointPath = join(home!, 'empty-path-endpoint.sh') + writeFileSync(endpointPath, 'curl() { /usr/bin/curl "$@"; }\n') const result = await runScript( scriptPath, - { ...orcaEnv(port), ORCA_ANTIGRAVITY_EVENT: 'Stop' }, + { + ...orcaEnv(port), + PATH: '', + ORCA_AGENT_HOOK_ENDPOINT: endpointPath, + ORCA_ANTIGRAVITY_EVENT: 'Stop' + }, ABANDONED_LINE_PAYLOAD, false ) @@ -225,9 +237,14 @@ describe.skipIf(process.platform === 'win32')('Antigravity POSIX hook payload de expect(result.exitCode).toBe(0) expect(result.elapsedMs).toBeLessThan(EXIT_BUDGET_MS) expect(result.stdout.trim()).toBe('{"decision":""}') - expect(posts.some((post) => post.hookEventName === 'Stop' && post.token === HOOK_TOKEN)).toBe( - true - ) + expect(result.stderr).toBe('') + expect(posts).toEqual([ + { + payload: ABANDONED_LINE_PAYLOAD.slice(0, -1), + hookEventName: 'Stop', + token: HOOK_TOKEN + } + ]) }) it('posts a closed-stdin Stop payload byte-exact', async () => { @@ -242,9 +259,10 @@ describe.skipIf(process.platform === 'win32')('Antigravity POSIX hook payload de expect(result.timedOut).toBe(false) expect(result.exitCode).toBe(0) expect(result.stdout.trim()).toBe('{"decision":""}') + expect(result.stderr).toBe('') expect(posts).toHaveLength(1) expect(posts[0]?.hookEventName).toBe('Stop') - expect(posts[0]?.payload).toBe(CLOSED_PAYLOAD.trimEnd()) + expect(posts[0]?.payload).toBe(CLOSED_PAYLOAD.slice(0, -1)) }) it('exits without waiting the watchdog when Orca env is missing', async () => { @@ -265,5 +283,6 @@ describe.skipIf(process.platform === 'win32')('Antigravity POSIX hook payload de expect(result.exitCode).toBe(0) expect(result.elapsedMs).toBeLessThan(500) expect(result.stdout.trim()).toBe(ANTIGRAVITY_PRE_TOOL_USE_DECISION) + expect(result.stderr).toBe('') }) })