test(agent-hooks): assert no managed Windows batch hook spawns an interpreter

The Antigravity drift was invisible to per-agent tests. The shared builder took
only `(source)` and could not add the extra form field Antigravity needs, so it
forked a private copy of the then-correct PowerShell command (#2100); when the
shared builder moved to curl (#6402) every caller was fixed for free and the
copy silently was not.

Assert the violated invariant across all ten batch-script agents at once, and
generate under a mocked win32 platform so the POSIX CI legs guard it too.
Verified to fail on the pre-fix script, naming the offending file.
This commit is contained in:
Neil
2026-08-19 17:15:16 -07:00
parent 2f0a42f8b3
commit 3da174f311
2 changed files with 141 additions and 2 deletions
@@ -0,0 +1,138 @@
// Why (#15117): Antigravity spent three months posting hook status through Windows
// PowerShell 5.1 after every other agent had moved to curl.exe. Nothing caught it, because
// the drift was invisible to per-agent tests: the shared builder took only `(source)` and had
// no way to add the extra form field Antigravity needs, so it forked a private copy of the
// then-correct PowerShell command (#2100). When the shared builder switched to curl (#6402),
// every agent that *called* it was fixed for free and the private copy silently was not.
//
// This suite asserts the invariant that drift violated, across every agent at once: a managed
// Windows .cmd hook posts through curl.exe and spawns no interpreter. It generates the scripts
// under a mocked win32 platform rather than executing them, so it guards the POSIX CI legs too
// — the drift would otherwise only be visible on a Windows runner.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as osModule from 'node:os'
let isolatedUserDataDir = ''
let previousUserDataPath: string | undefined
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 '../antigravity/hook-service'
import { ClaudeHookService } from '../claude/hook-service'
import { CodexHookService } from '../codex/hook-service'
import { CommandCodeHookService } from '../command-code/hook-service'
import { CursorHookService } from '../cursor/hook-service'
import { DevinHookService } from '../devin/hook-service'
import { DroidHookService } from '../droid/hook-service'
import { GeminiHookService } from '../gemini/hook-service'
import { GrokHookService } from '../grok/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
// Why: only agents whose managed Windows script is a .cmd batch file. Copilot's hook is a
// `.ps1` — PowerShell is its interpreter, not a child process it spawns per event — and Kimi's
// is a Git Bash `.sh`, so neither is subject to this invariant.
const BATCH_SCRIPT_INSTALLERS = [
{ agent: 'antigravity', install: () => new AntigravityHookService().install() },
{ agent: 'claude', install: () => new ClaudeHookService().install() },
{ agent: 'openclaude', install: () => openClaudeHookService.install() },
{ agent: 'codex', install: () => new CodexHookService().install() },
{ agent: 'command-code', install: () => new CommandCodeHookService().install() },
{ agent: 'cursor', install: () => new CursorHookService().install() },
{ agent: 'devin', install: () => new DevinHookService().install() },
{ agent: 'droid', install: () => new DroidHookService().install() },
{ agent: 'gemini', install: () => new GeminiHookService().install() },
{ agent: 'grok', install: () => new GrokHookService().install() }
] as const
function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return run()
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
}
describe('Windows managed hook post interpreter', () => {
let home = ''
beforeEach(() => {
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
isolatedUserDataDir = mkdtempSync(join(tmpdir(), 'orca-hook-interpreter-user-data-'))
// Why: Orca-managed Codex hooks resolve through ORCA_USER_DATA_PATH before the mocked
// home; an inherited live path would let this test rewrite the developer's own hooks.
process.env.ORCA_USER_DATA_PATH = isolatedUserDataDir
home = mkdtempSync(join(tmpdir(), 'orca-hook-interpreter-'))
homedirMock.mockReturnValue(home)
})
afterEach(() => {
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
rmSync(isolatedUserDataDir, { recursive: true, force: true })
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
rmSync(home, { recursive: true, force: true })
home = ''
})
it('posts through curl.exe from every managed batch script, spawning no interpreter', () => {
const scripts = withPlatform('win32', () => {
for (const entry of BATCH_SCRIPT_INSTALLERS) {
expect(entry.install().state, `${entry.agent} install status`).toBe('installed')
}
const hooksDir = join(home, '.orca', 'agent-hooks')
return readdirSync(hooksDir)
.filter((name) => name.endsWith('.cmd'))
.map((name) => ({ name, body: readFileSync(join(hooksDir, name), 'utf8') }))
})
// Why: a silent zero would make this suite pass while asserting nothing.
expect(scripts.length).toBeGreaterThanOrEqual(BATCH_SCRIPT_INSTALLERS.length)
const posting = scripts.filter((script) => script.body.includes('/hook/'))
// Why: every agent in the matrix owns exactly one posting script; event wrappers that only
// set an env var and delegate are not expected to post themselves.
expect(posting.length).toBeGreaterThanOrEqual(BATCH_SCRIPT_INSTALLERS.length)
for (const script of posting) {
// Why: PowerShell costs ~300ms of interpreter startup per event and recodes the UTF-8
// payload through the console code page. curl.exe (Win10 1803+) does neither.
expect(script.body, `${script.name} must not spawn an interpreter`).not.toMatch(
/powershell(\.exe)?/i
)
// Why: fully qualified so a repo-local curl.exe cannot intercept hook payloads.
expect(script.body, `${script.name} must post through curl.exe`).toContain(
'"%SystemRoot%\\System32\\curl.exe"'
)
// Why: keeps multi-KB tool output off the command line (EDR oversized-command-line rules).
expect(script.body, `${script.name} must pipe the payload via stdin`).toContain(
'--data-urlencode "payload@-"'
)
}
})
})
+3 -2
View File
@@ -12,8 +12,9 @@ import { ANTIGRAVITY_PRE_TOOL_USE_DECISION } from './hook-events'
// allocates for each hook visible; curl.exe (Win10 1803+) finishes in ~10ms and, unlike
// PowerShell, does not recode the UTF-8 payload through the console code page.
const WINDOWS_ANTIGRAVITY_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('antigravity', [
// Why: the event name lives in the wrapper's env, not the payload, and the listener
// reads it as a top-level form field — same position as the POSIX branch below.
// Why: Antigravity alone takes its event name from the wrapper's env rather than the piped
// payload. Before `extraFormLines` existed this forced a private copy of the shared command,
// which is how this script missed the fleet-wide move to curl for three months (#15117).
' --data-urlencode "hook_event_name=%ORCA_ANTIGRAVITY_EVENT%" ^'
])