diff --git a/src/main/agent-hooks/windows-hook-post-interpreter.test.ts b/src/main/agent-hooks/windows-hook-post-interpreter.test.ts new file mode 100644 index 00000000000..e495f2d01ca --- /dev/null +++ b/src/main/agent-hooks/windows-hook-post-interpreter.test.ts @@ -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() + 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(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@-"' + ) + } + }) +}) diff --git a/src/main/antigravity/hook-script.ts b/src/main/antigravity/hook-script.ts index e12fd7cd290..1c03a13cd8d 100644 --- a/src/main/antigravity/hook-script.ts +++ b/src/main/antigravity/hook-script.ts @@ -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%" ^' ])