diff --git a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs index 8f594ff9506..3011e6fc123 100644 --- a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs +++ b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs @@ -140,10 +140,23 @@ function readGeneratedScripts(home, minMtime) { throw new Error([fileName, ' predates the Electron launch'].join('')) } const body = readFileSync(path, 'utf8') - const captureIndex = body.indexOf('payload=$(cat)') - const firstExitIndex = body.indexOf('exit 0') - if (captureIndex === -1 || firstExitIndex <= captureIndex) { - throw new Error([fileName, ' can exit before capturing stdin'].join('')) + if (fileName === 'antigravity-hook.sh') { + const guardIndex = body.indexOf('[ -z "$ORCA_PANE_KEY" ]') + const captureIndex = body.indexOf('exec 3<&0') + if ( + guardIndex === -1 || + captureIndex === -1 || + captureIndex <= guardIndex || + !body.includes('kill -9 "$_orca_cat"') + ) { + throw new Error([fileName, ' is missing the bounded stdin watchdog'].join('')) + } + } else { + const captureIndex = body.indexOf('payload=$(cat)') + const firstExitIndex = body.indexOf('exit 0') + if (captureIndex === -1 || firstExitIndex <= captureIndex) { + throw new Error([fileName, ' can exit before capturing stdin'].join('')) + } } return { body, fileName, path, source } }) 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 c52d6656013..ddade5c22ea 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -191,6 +191,18 @@ function runPosixHook(command: string, extraEnv: NodeJS.ProcessEnv = {}): Promis return runHookProcess('/bin/sh', ['-c', command], hookEnvironment(extraEnv)) } +function posixOrcaEnvIfRequired(agent: string): NodeJS.ProcessEnv { + // Why: command-code and Antigravity env-guard before stdin; without these the writer sees EPIPE. + if (agent.startsWith('command-code') || agent.startsWith('antigravity')) { + return { + ORCA_AGENT_HOOK_PORT: '1', + ORCA_AGENT_HOOK_TOKEN: 'test-token', + ORCA_PANE_KEY: 'test-pane' + } + } + return {} +} + async function generatePosixScripts(): Promise> { const scripts = new Map() for (const entry of REMOTE_INSTALLERS) { @@ -461,6 +473,15 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () it('captures stdin before every possible whole-script success exit', async () => { const scripts = await generatePosixScripts() for (const [agent, script] of scripts) { + if (agent.startsWith('antigravity')) { + // Why: Antigravity may abandon stdin; missing Orca env must exit before cat (#11549). + const guardIndex = script.indexOf('[ -z "$ORCA_PANE_KEY" ]') + 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"') + continue + } const captureIndex = script.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`) const firstExitIndex = script.indexOf('exit 0') expect(captureIndex, `${agent} payload capture`).toBeGreaterThanOrEqual(0) @@ -471,13 +492,7 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () it('accepts a large payload without Orca environment or a broken writer', async () => { const scripts = await generatePosixScripts() for (const [agent, script] of scripts) { - const extraEnv = agent.startsWith('command-code') - ? { - ORCA_AGENT_HOOK_PORT: '1', - ORCA_AGENT_HOOK_TOKEN: 'test-token', - ORCA_PANE_KEY: 'test-pane' - } - : {} + const extraEnv = posixOrcaEnvIfRequired(agent) const result = await runPosixHook(script, extraEnv) expect(result.exitCode, `${agent} exit code`).toBe(0) expect(result.stdinErrors, `${agent} stdin errors`).toHaveLength(0) @@ -487,7 +502,7 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () it('does not need PATH to capture or drain POSIX hook stdin', async () => { const scripts = await generatePosixScripts() for (const [agent, script] of scripts) { - const result = await runPosixHook(script, { PATH: '' }) + const result = await runPosixHook(script, { PATH: '', ...posixOrcaEnvIfRequired(agent) }) expect(result.exitCode, `${agent} exit code`).toBe(0) expect(result.stdinErrors, `${agent} stdin errors`).toHaveLength(0) } diff --git a/src/main/antigravity/hook-script.ts b/src/main/antigravity/hook-script.ts index 0645416c005..4459274e72f 100644 --- a/src/main/antigravity/hook-script.ts +++ b/src/main/antigravity/hook-script.ts @@ -1,12 +1,16 @@ import { - buildPosixHookPayloadCapture, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, + POSIX_HOOK_STDIN_READER, WINDOWS_HOOK_STDIN_DRAIN_COMMAND } from '../agent-hooks/hook-stdin-contract' import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils' import { ANTIGRAVITY_PRE_TOOL_USE_DECISION } from './hook-events' +// Why: agy can omit stdin or leave the pipe open until the hook exits (#15117/#11549). +// POSIX capture is watchdog-killed at this bound; tests reuse it so the budget cannot drift. +export const ANTIGRAVITY_POSIX_STDIN_WATCHDOG_SECONDS = 1 + // Why (#15117): PowerShell cost ~300ms of startup per event, which is what made the console // the agent allocates for each hook last long enough to see. const WINDOWS_ANTIGRAVITY_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('antigravity', [ @@ -52,15 +56,30 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' printf "{}\\n"', ' ;;', 'esac', - // 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'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', + // Why: hanging is worse than EPIPE when the caller may abandon stdin (#11549). 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', + // Why: agy can omit stdin or leave the pipe open until the hook exits (#15117). + // Background cat otherwise inherits /dev/null. Dup stdin to fd 3 first. + // 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 &`, + ' _orca_watch=$!', + ' wait "$_orca_cat"', + ' kill -9 "$_orca_watch" 2>/dev/null', + ' wait "$_orca_watch" 2>/dev/null', + ')', + 'exec 3<&-', + 'if [ -z "$payload" ]; then', + " payload='{}'", + 'fi', // Timeout caps best-effort hook posts if the local listener stalls. // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl diff --git a/src/main/antigravity/hook-service.test.ts b/src/main/antigravity/hook-service.test.ts index b9f69b03cc1..d82abae6c4b 100644 --- a/src/main/antigravity/hook-service.test.ts +++ b/src/main/antigravity/hook-service.test.ts @@ -106,7 +106,11 @@ describe('AntigravityHookService', () => { expect(script).toContain('setlocal DisableDelayedExpansion') } else { expect(script).toContain('hook_event_name=${ORCA_ANTIGRAVITY_EVENT}') - expect(script).toContain(`payload=$(${POSIX_HOOK_STDIN_READER})`) + expect(script).toContain(`${POSIX_HOOK_STDIN_READER} <&3 &`) + expect(script).toContain('command -p sleep') + expect(script).toContain('kill -9 "$_orca_cat"') + expect(script).toContain('exec 3<&0') + expect(script.indexOf('[ -z "$ORCA_PANE_KEY" ]')).toBeLessThan(script.indexOf('_orca_cat=$!')) 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 new file mode 100644 index 00000000000..40677122d92 --- /dev/null +++ b/src/main/antigravity/posix-hook-payload-delivery.test.ts @@ -0,0 +1,269 @@ +// Why: Antigravity can omit stdin or leave the pipe open until the hook exits. +// Shape assertions on the script text cannot catch an unbounded `cat`. +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 { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as osModule from 'node:os' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-user-data' + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock.mockImplementation(actual.homedir) + } +}) + +import { AntigravityHookService } from './hook-service' +import { ANTIGRAVITY_PRE_TOOL_USE_DECISION } from './hook-events' +import { ANTIGRAVITY_POSIX_STDIN_WATCHDOG_SECONDS } from './hook-script' + +const PANE_KEY = 'tab-1:leaf-1' +const WORKTREE_ID = 'repo-1::/tmp/feature' +const HOOK_TOKEN = 'antigravity-posix-payload-delivery-token' +const CLOSED_PAYLOAD = `${JSON.stringify({ + fullyIdle: true, + toolCall: { name: 'run_command', args: { CommandLine: 'pwd' } }, + transcriptPath: '/tmp/antigravity-transcript.jsonl' +})}\n` +const ABANDONED_LINE_PAYLOAD = `${JSON.stringify({ fullyIdle: false })}\n` + +type HookPost = { + payload: string | null + hookEventName: string | null + token: string | null +} + +async function startHookListener(): Promise<{ + server: Server + port: number + posts: HookPost[] +}> { + const posts: HookPost[] = [] + const server = createServer((req, res) => { + let body = '' + req.setEncoding('utf8') + req.on('data', (chunk) => { + body += chunk + }) + req.on('end', () => { + const form = new URLSearchParams(body) + posts.push({ + payload: form.get('payload'), + hookEventName: form.get('hook_event_name'), + token: (req.headers['x-orca-agent-hook-token'] as string | undefined) ?? null + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{}') + }) + }) + const port = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const address = server.address() + resolve(typeof address === 'object' && address ? address.port : 0) + }) + }) + return { server, port, posts } +} + +type HookRun = { + exitCode: number | null + stdout: string + timedOut: boolean + elapsedMs: number +} + +const EXIT_BUDGET_MS = (ANTIGRAVITY_POSIX_STDIN_WATCHDOG_SECONDS + 2) * 1000 + +function runScript( + scriptPath: string, + env: NodeJS.ProcessEnv, + stdinPayload: string | null, + closeStdin: boolean +): Promise { + return new Promise((resolve, reject) => { + const startedAt = Date.now() + const child = spawn('/bin/sh', [scriptPath], { stdio: ['pipe', 'pipe', 'pipe'], env }) + let stdout = '' + let timedOut = false + const timer = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + }, EXIT_BUDGET_MS + 1000) + child.on('error', (error) => { + clearTimeout(timer) + reject(error) + }) + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + }) + child.stderr.on('data', () => {}) + child.on('close', (exitCode) => { + clearTimeout(timer) + setTimeout( + () => + resolve({ + exitCode, + stdout, + timedOut, + elapsedMs: Date.now() - startedAt + }), + 250 + ) + }) + child.stdin.on('error', () => {}) + if (stdinPayload !== null) { + child.stdin.write(stdinPayload) + } + if (closeStdin) { + child.stdin.end() + } + }) +} + +function hookEnvironment(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const base = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_')) + ) + return { ...base, ...extra } +} + +function expectedStdout(eventName: string): string { + if (eventName === 'PreToolUse') { + return ANTIGRAVITY_PRE_TOOL_USE_DECISION + } + return eventName === 'Stop' ? '{"decision":""}' : '{}' +} + +describe.skipIf(process.platform === 'win32')('Antigravity POSIX hook payload delivery', () => { + let home: string | undefined + let server: Server | undefined + + afterEach(() => { + server?.close() + server = undefined + if (home) { + rmSync(home, { recursive: true, force: true }) + home = undefined + } + }) + + async function installAndListen(): Promise<{ + scriptPath: string + port: number + posts: HookPost[] + }> { + home = mkdtempSync(join(tmpdir(), 'orca-antigravity-posix-hook-')) + homedirMock.mockReturnValue(home) + expect(new AntigravityHookService().install().state).toBe('installed') + const listener = await startHookListener() + server = listener.server + return { + scriptPath: join(home, '.orca', 'agent-hooks', 'antigravity-hook.sh'), + port: listener.port, + posts: listener.posts + } + } + + function orcaEnv(port: number): NodeJS.ProcessEnv { + return hookEnvironment({ + HOME: home, + ORCA_AGENT_HOOK_PORT: String(port), + ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN, + ORCA_PANE_KEY: PANE_KEY, + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: WORKTREE_ID, + ORCA_AGENT_HOOK_ENV: 'production', + ORCA_AGENT_HOOK_VERSION: '1', + ORCA_AGENT_HOOK_ENDPOINT: '' + }) + } + + it.each(['PreInvocation', 'PreToolUse', 'Stop'] as const)( + 'exits when %s stdin is abandoned with Orca env present', + async (eventName) => { + const { scriptPath, port, posts } = await installAndListen() + const result = await runScript( + scriptPath, + { ...orcaEnv(port), ORCA_ANTIGRAVITY_EVENT: eventName }, + null, + false + ) + + expect(result.timedOut).toBe(false) + 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) + } + ) + + it('exits when a payload line is written but stdin is never closed', async () => { + const { scriptPath, port, posts } = await installAndListen() + const result = await runScript( + scriptPath, + { ...orcaEnv(port), ORCA_ANTIGRAVITY_EVENT: 'Stop' }, + ABANDONED_LINE_PAYLOAD, + false + ) + + expect(result.timedOut).toBe(false) + 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 + ) + }) + + it('posts a closed-stdin Stop payload byte-exact', async () => { + const { scriptPath, port, posts } = await installAndListen() + const result = await runScript( + scriptPath, + { ...orcaEnv(port), ORCA_ANTIGRAVITY_EVENT: 'Stop' }, + CLOSED_PAYLOAD, + true + ) + + expect(result.timedOut).toBe(false) + expect(result.exitCode).toBe(0) + expect(result.stdout.trim()).toBe('{"decision":""}') + expect(posts).toHaveLength(1) + expect(posts[0]?.hookEventName).toBe('Stop') + expect(posts[0]?.payload).toBe(CLOSED_PAYLOAD.trimEnd()) + }) + + it('exits without waiting the watchdog when Orca env is missing', async () => { + const { scriptPath, port } = await installAndListen() + const result = await runScript( + scriptPath, + hookEnvironment({ + HOME: home, + ORCA_ANTIGRAVITY_EVENT: 'PreToolUse', + ORCA_AGENT_HOOK_PORT: String(port), + ORCA_AGENT_HOOK_ENDPOINT: '' + }), + null, + false + ) + + expect(result.timedOut).toBe(false) + expect(result.exitCode).toBe(0) + expect(result.elapsedMs).toBeLessThan(500) + expect(result.stdout.trim()).toBe(ANTIGRAVITY_PRE_TOOL_USE_DECISION) + }) +})