mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(antigravity): bound POSIX hook stdin so agy cannot hang loading
Antigravity can omit stdin or leave the pipe open until the hook exits. Unbound cat never reached curl, so the CLI waited on the hook and the sidebar stayed working. Capture through a watchdog-killed cat after the Orca env guard, without changing the shared POSIX stdin contract.
This commit is contained in:
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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<Map<string, string>> {
|
||||
const scripts = new Map<string, string>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof osModule>()
|
||||
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<number>((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<HookRun> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user