fix(agent-hooks): keep Antigravity status posts alive when an event carries no payload

The PowerShell post substituted `{}` for empty stdin, and the POSIX script does
the same before posting. curl forwards stdin verbatim, so a payload-less event
now arrives as `payload=""` and the shared ingest dropped it — losing the status
transition its `hook_event_name` still carried.

Accept an empty Antigravity payload as `{}` at the ingest boundary, scoped to
that source so every other agent keeps rejecting a body it cannot parse.
This commit is contained in:
Neil
2026-08-19 12:58:45 -07:00
parent 5216a8008f
commit 61e695c52f
4 changed files with 94 additions and 3 deletions
@@ -96,7 +96,11 @@ async function startHookListener(): Promise<{
type HookRun = { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean }
function runWrapper(wrapperPath: string, env: NodeJS.ProcessEnv): Promise<HookRun> {
function runWrapper(
wrapperPath: string,
env: NodeJS.ProcessEnv,
stdinPayload: string = PAYLOAD
): Promise<HookRun> {
return new Promise((resolve, reject) => {
// Why: mirror how Antigravity spawns the hook — `cmd /c <bare .cmd path>`, the exact
// chain in the bug report's process trace.
@@ -127,7 +131,7 @@ function runWrapper(wrapperPath: string, env: NodeJS.ProcessEnv): Promise<HookRu
// Why: curl exits once the POST is written; give the listener a beat to finish reading it.
setTimeout(() => resolve({ exitCode, stdout, stderr, timedOut }), 250)
})
child.stdin.end(Buffer.from(PAYLOAD, 'utf8'))
child.stdin.end(Buffer.from(stdinPayload, 'utf8'))
})
}
@@ -227,6 +231,35 @@ describe.skipIf(process.platform !== 'win32')('Antigravity Windows hook payload
// Why: five wrapper launches plus a real install can overrun the default under load.
}, 60_000)
// Why (#15117): Antigravity fires some events with no stdin at all. PowerShell substituted
// `{}` before posting; curl forwards the empty body, so prove the post still happens — the
// listener's matching allowance is covered in agent-hook-listener-antigravity.test.ts.
it('still posts a status event when the agent supplies no payload', async () => {
home = mkdtempSync(join(tmpdir(), 'orca-antigravity-hook-'))
homedirMock.mockReturnValue(home)
expect(new AntigravityHookService().install().state).toBe('installed')
const listener = await startHookListener()
server = listener.server
const result = await runWrapper(
join(home, '.orca', 'agent-hooks', 'antigravity-pre-invocation.cmd'),
hookEnvironment({
USERPROFILE: home,
HOME: home,
ORCA_AGENT_HOOK_PORT: String(listener.port),
ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN,
ORCA_PANE_KEY: PANE_KEY
}),
''
)
expect(result.timedOut).toBe(false)
expect(result.exitCode).toBe(0)
expect(listener.posts).toHaveLength(1)
expect(listener.posts[0].payload).toBe('')
expect(listener.posts[0].hookEventName).toBe('PreInvocation')
}, 30_000)
it('exits without reading stdin when the pane env is missing', async () => {
home = mkdtempSync(join(tmpdir(), 'orca-antigravity-hook-'))
homedirMock.mockReturnValue(home)
@@ -23,6 +23,57 @@ describe('shared agent-hook-listener', () => {
vi.unstubAllEnvs()
})
// Why (#15117): the Windows script posts stdin verbatim through curl, so an event that
// fires without a payload arrives as `payload=""` rather than the `{}` the POSIX script
// substitutes. Both transports must still produce the status transition.
it('accepts an Antigravity event whose payload arrived empty', () => {
const started = normalizeHookPayload(
state,
'antigravity',
{
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt',
hook_event_name: 'PreInvocation',
payload: ''
},
'production'
)
expect(started?.payload).toMatchObject({ state: 'working', agentType: 'antigravity' })
})
it('still drops an Antigravity event whose payload is malformed rather than empty', () => {
expect(
normalizeHookPayload(
state,
'antigravity',
{
paneKey: PANE_KEY,
hook_event_name: 'PreInvocation',
payload: '{"prompt":'
},
'production'
)
).toBeNull()
})
// Why: the empty-payload allowance is Antigravity's documented policy, not a global one —
// every other source must keep rejecting a body it cannot parse.
it('does not extend the empty-payload allowance to other sources', () => {
expect(
normalizeHookPayload(
state,
'gemini',
{
paneKey: PANE_KEY,
hook_event_name: 'BeforeAgent',
payload: ''
},
'production'
)
).toBeNull()
})
it('normalizes Antigravity invocation and tool hooks', () => {
const started = normalizeHookPayload(
state,
+7
View File
@@ -4369,6 +4369,13 @@ export function normalizeHookPayload(
const hookPayload =
typeof rawPayload === 'string'
? (() => {
// Why (#15117): some Antigravity events fire with no stdin at all, yet still carry a
// status transition in `hook_event_name`. The POSIX script maps empty input to `{}`
// before posting; the Windows script pipes stdin straight into curl and cannot, so
// accept the empty body here instead of dropping the event.
if (source === 'antigravity' && rawPayload.trim() === '') {
return {}
}
try {
return parseAgentHookJson(rawPayload)
} catch {
+1 -1
View File
@@ -85,7 +85,7 @@ describe('wsl.exe mode separator', () => {
it('sees a `--` separator split across lines by the formatter', () => {
// Why: the guard once matched line-by-line and was blind to this exact shape,
// which is how every multi-element argv array in this repo is formatted.
const formatted = ["args: [", " '-d',", " distro,", " '--',", " 'bash'", "]"].join('\n')
const formatted = ['args: [', " '-d',", ' distro,', " '--',", " 'bash'", ']'].join('\n')
expect(ARGV_FORM.test(codeText(formatted))).toBe(true)
})