mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
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:
@@ -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@-"'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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%" ^'
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user