fix(antigravity): bound Windows hook stdin on the owning runtime (#24622)

* fix(antigravity): bound Windows hook stdin before posting status

* fix(antigravity): publish Windows hook companion before core

* test(antigravity): name Windows hook payload cases explicitly
This commit is contained in:
Neil
2026-10-02 17:48:24 -07:00
committed by GitHub
parent e80b550d23
commit bdaeb6cbd8
12 changed files with 406 additions and 58 deletions
+1
View File
@@ -103,6 +103,7 @@
"../src/main/amp/managed-plugin-install-status.ts",
"../src/main/antigravity/hook-events.ts",
"../src/main/antigravity/hook-script.ts",
"../src/main/antigravity/windows-hook-json-post.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/antigravity/hooks-json-bundle.ts",
"../src/main/claude/hook-settings.ts",
@@ -300,6 +300,11 @@ describe('Windows managed hook stdin structure', () => {
expect(script, `${fileName} no ORCA_* guard may route to the more.com drain`).not.toMatch(
/ORCA_[A-Z_]+.*goto :?orca_agent_hook_drain_stdin/
)
if (fileName === 'antigravity-hook.cmd') {
expect(script).not.toContain('more.com')
expect(script).toContain('antigravity-hook-post.cjs')
continue
}
// Why: the epilogue stays shared — claude-hook-impl.cmd still jumps to it from the
// Devin-imports-.claude skip, which now sits below these guards.
expect(script, `${fileName} drain epilogue`).toContain(
@@ -1,6 +1,7 @@
// Why (#15117): an agent holding a private copy of the shared post command missed the move to
// curl for three months, invisible to per-agent tests. Assert the invariant across every agent
// at once: a managed Windows .cmd hook posts through curl.exe and spawns no interpreter.
// at once: EOF-based managed Windows .cmd hooks post through curl.exe.
// Antigravity keeps stdin open and instead tests its owned bounded Node reader separately.
// Generated under a mocked win32 platform, not executed, so the POSIX CI legs guard it too.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
@@ -33,7 +34,6 @@ vi.mock('os', async (importOriginal) => {
}
})
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'
@@ -48,7 +48,6 @@ import { openClaudeHookService } from '../openclaude/hook-service'
// `.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() },
+9 -15
View File
@@ -2,21 +2,14 @@ import {
buildPosixHookPayloadCapture,
POSIX_HOOK_JSON_STDIN,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue,
WINDOWS_HOOK_STDIN_DRAIN_COMMAND
buildWindowsHookEnvironmentGuardLines
} from '../agent-hooks/hook-stdin-contract'
import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils'
import { ANTIGRAVITY_PRE_TOOL_USE_DECISION } from './hook-events'
// 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', [
// Why: Antigravity alone takes its event name from the wrapper's env, not the piped payload.
' --data-urlencode "hook_event_name=%ORCA_ANTIGRAVITY_EVENT%" ^'
])
export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
export function getManagedScript(
target: 'local' | 'posix' = 'local',
windowsRuntimePath = process.execPath
): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
@@ -32,9 +25,11 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
')',
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
...buildWindowsHookEnvironmentGuardLines(),
WINDOWS_ANTIGRAVITY_HOOK_POST_COMMAND,
// The runtime path is fixed at installation; hook payloads stay on stdin.
'set "ELECTRON_RUN_AS_NODE=1"',
`if not defined ORCA_AGENT_HOOK_NODE set "ORCA_AGENT_HOOK_NODE=${windowsRuntimePath.replaceAll('%', '%%')}"`,
'"%ORCA_AGENT_HOOK_NODE%" "%~dp0antigravity-hook-post.cjs" >nul 2>nul',
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
''
].join('\r\n')
}
@@ -108,7 +103,6 @@ export function getWindowsWrapperScript(eventName: string): string {
')',
// Missing-core fallbacks obey the same outside-Orca stdin guard as the core.
...buildWindowsHookEnvironmentGuardLines(),
WINDOWS_HOOK_STDIN_DRAIN_COMMAND,
'exit /b 0',
''
].join('\r\n')
+54 -8
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
@@ -99,12 +99,15 @@ describe('AntigravityHookService', () => {
join(homeDir, '.orca', 'agent-hooks', ANTIGRAVITY_SCRIPT_FILE_NAME),
'utf8'
)
expect(script).toContain('/hook/antigravity')
expect(script).toContain(
process.platform === 'win32' ? 'antigravity-hook-post.cjs' : '/hook/antigravity'
)
if (process.platform === 'win32') {
expect(script).not.toContain('powershell.exe')
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
expect(script).toContain('hook_event_name=%ORCA_ANTIGRAVITY_EVENT%')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).toContain('ELECTRON_RUN_AS_NODE=1')
expect(
readFileSync(join(homeDir, '.orca', 'agent-hooks', 'antigravity-hook-post.cjs'), 'utf8')
).toContain('/hook/antigravity')
// Why (#9358/#9941): delayed expansion eats `!` out of percent-expanded curl args.
expect(script).toContain('setlocal DisableDelayedExpansion')
} else {
@@ -279,14 +282,57 @@ describe('AntigravityHookService', () => {
join(homeDir, '.orca', 'agent-hooks', 'antigravity-hook.cmd'),
'utf8'
)
expect(script).toContain('/hook/antigravity')
expect(script).toContain('antigravity-hook-post.cjs')
expect(script).not.toContain('powershell.exe')
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
expect(script).toContain('hook_event_name=%ORCA_ANTIGRAVITY_EVENT%')
expect(script).toContain('ELECTRON_RUN_AS_NODE=1')
expect(
readFileSync(join(homeDir, '.orca', 'agent-hooks', 'antigravity-hook-post.cjs'), 'utf8')
).toContain('/hook/antigravity')
expect(script).toContain('setlocal DisableDelayedExpansion')
})
})
it('preserves the installed core and config when publishing the Windows reader fails', () => {
withPlatform('win32', () => {
const service = new AntigravityHookService()
expect(service.install().state).toBe('installed')
const hookDir = join(homeDir, '.orca', 'agent-hooks')
const readerPath = join(hookDir, 'antigravity-hook-post.cjs')
const corePath = join(hookDir, 'antigravity-hook.cmd')
const configPath = join(homeDir, '.gemini', 'config', 'hooks.json')
writeFileSync(corePath, 'previous installed core')
const previousConfig = readFileSync(configPath, 'utf8')
rmSync(readerPath)
mkdirSync(readerPath)
expect(() => service.install()).toThrow()
expect(readFileSync(corePath, 'utf8')).toBe('previous installed core')
expect(readFileSync(configPath, 'utf8')).toBe(previousConfig)
})
})
it('restores the owned Windows reader and resolves the current runtime on refresh', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
try {
const service = new AntigravityHookService()
let runtimePath = 'C:\\Orca1\\Orca.exe'
service.setWindowsRuntimePathProvider(() => runtimePath)
const readerPath = join(homeDir, '.orca', 'agent-hooks', 'antigravity-hook-post.cjs')
await service.refreshManagedScripts()
expect(existsSync(readerPath)).toBe(false)
expect(service.install().state).toBe('installed')
rmSync(readerPath)
runtimePath = 'C:\\Orca2\\Orca.exe'
await service.refreshManagedScripts()
expect(readFileSync(readerPath, 'utf8')).toContain("require('node:string_decoder')")
expect(
readFileSync(join(homeDir, '.orca', 'agent-hooks', 'antigravity-hook.cmd'), 'utf8')
).toContain('ORCA_AGENT_HOOK_NODE=C:\\Orca2\\Orca.exe')
} finally {
vi.restoreAllMocks()
}
})
it('preserves user-authored hook bundles and entries in Orca bundle', () => {
const configPath = join(homeDir, '.gemini', 'config', 'hooks.json')
mkdirSync(dirname(configPath), { recursive: true })
+30 -4
View File
@@ -16,7 +16,12 @@ import {
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import { WINDOWS_ANTIGRAVITY_JSON_POST_SCRIPT } from './windows-hook-json-post'
import {
restoreManagedScript,
refreshManagedScriptIfPresent,
scriptStillExists
} from '../agent-hooks/managed-hook-script-refresh'
import {
ANTIGRAVITY_EVENTS,
ANTIGRAVITY_PRE_TOOL_USE_DECISION,
@@ -70,9 +75,19 @@ function getManagedCommand(scriptPath: string, event: AntigravityEvent): string
}
export class AntigravityHookService {
private getWindowsRuntimePath = (): string => process.execPath
setWindowsRuntimePathProvider(provider: () => string): void {
this.getWindowsRuntimePath = provider
}
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
if (process.platform === 'win32') {
const runtimePath = process.platform === 'win32' ? this.getWindowsRuntimePath() : undefined
if (process.platform === 'win32' && (await scriptStillExists(getManagedScriptPath()))) {
await restoreManagedScript(
getSharedManagedScriptPath('antigravity-hook-post.cjs'),
WINDOWS_ANTIGRAVITY_JSON_POST_SCRIPT
)
for (const event of ANTIGRAVITY_EVENTS) {
await refreshManagedScriptIfPresent(
getWindowsWrapperScriptPath(event),
@@ -80,6 +95,10 @@ export class AntigravityHookService {
)
}
}
await refreshManagedScriptIfPresent(
getManagedScriptPath(),
getManagedScript('local', runtimePath)
)
}
getStatus(): AgentHookInstallStatus {
@@ -157,7 +176,14 @@ export class AntigravityHookService {
(event) => getManagedCommand(scriptPath, event),
createAntigravityManagedCommandMatcher()
)
writeManagedScript(scriptPath, getManagedScript())
const runtimePath = process.platform === 'win32' ? this.getWindowsRuntimePath() : undefined
if (process.platform === 'win32') {
writeManagedScript(
getSharedManagedScriptPath('antigravity-hook-post.cjs'),
WINDOWS_ANTIGRAVITY_JSON_POST_SCRIPT
)
}
writeManagedScript(scriptPath, getManagedScript('local', runtimePath))
if (process.platform === 'win32') {
// Why: Antigravity wraps hook commands in cmd.exe. Keeping event env
// setup inside event-specific .cmd files avoids nested hooks.json quotes.
@@ -0,0 +1,143 @@
import { createServer } from 'node:http'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, expect, it } from 'vitest'
import { spawnProcess } from '../../shared/child-process/run-process'
import { WINDOWS_ANTIGRAVITY_JSON_POST_SCRIPT } from './windows-hook-json-post'
const directory = mkdtempSync(join(tmpdir(), 'orca-agy-json-post-'))
const script = join(directory, 'hook.cjs')
writeFileSync(script, WINDOWS_ANTIGRAVITY_JSON_POST_SCRIPT)
afterAll(() => rmSync(directory, { recursive: true, force: true }))
it('delivers exact split UTF-8 JSON without EOF and bounds empty or partial input', async () => {
const posts: URLSearchParams[] = []
const server = createServer((request, response) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => {
body += chunk
})
request.on('end', () => {
posts.push(new URLSearchParams(body))
response.end('{}')
})
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('Missing listener')
}
const env = {
...process.env,
ORCA_AGENT_HOOK_PORT: String(address.port),
ORCA_AGENT_HOOK_TOKEN: 'disposable-proof-token',
ORCA_PANE_KEY: 'proof!pane',
ORCA_ANTIGRAVITY_EVENT: 'PreInvocation'
}
const payload = JSON.stringify({
message: '日本語 😀 café {"quoted"} \\ tail',
transcript: 'x'.repeat(100_000)
})
try {
for (const input of [payload, '', '{"partial":', '{} trailing-data']) {
const child = spawnProcess({ program: process.execPath, args: [script], env })
child.stdin.on('error', () => {})
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString()
})
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString()
})
const timer = setTimeout(() => child.kill(), 9000)
const closed = new Promise<number | null>((resolve, reject) => {
child.once('close', resolve)
child.once('error', reject)
})
const before = posts.length
if (input) {
const bytes = Buffer.from(input)
const cut = bytes.indexOf(Buffer.from('日本語')) + 1
child.stdin.write(bytes.subarray(0, Math.max(1, cut)))
await new Promise((resolve) => setTimeout(resolve, 25))
child.stdin.write(bytes.subarray(Math.max(1, cut)))
}
expect(await closed).toBe(0)
clearTimeout(timer)
child.stdin.destroy()
expect(stdout).toBe('')
expect(stderr).toBe('')
expect(posts.slice(before)).toHaveLength(1)
expect(posts[before].get('payload')).toBe(input || '{}')
expect(posts[before].get('paneKey')).toBe('proof!pane')
expect(posts[before].get('hook_event_name')).toBe('PreInvocation')
}
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()))
}
}, 20_000)
it('bounds oversized payloads, encoded bodies, absent endpoints and stalled HTTP', async () => {
let requests = 0
const server = createServer((request) => {
requests++
request.resume()
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('Missing listener')
}
const env = {
...process.env,
ORCA_AGENT_HOOK_PORT: String(address.port),
ORCA_AGENT_HOOK_TOKEN: 'disposable-proof-token',
ORCA_PANE_KEY: 'proof-pane'
}
try {
for (const payloadCase of ['oversized', 'encoded-oversized', 'no-endpoint', 'hung-http']) {
const before = requests
const child = spawnProcess({
program: process.execPath,
args: [script],
env: payloadCase === 'no-endpoint' ? { ...env, ORCA_AGENT_HOOK_PORT: '' } : env
})
child.stdin.on('error', () => {})
let output = ''
child.stdout.on('data', (chunk: Buffer) => {
output += chunk.toString()
})
child.stderr.on('data', (chunk: Buffer) => {
output += chunk.toString()
})
const timer = setTimeout(() => child.kill(), 4000)
const closed = new Promise<number | null>((resolve, reject) => {
child.once('close', resolve)
child.once('error', reject)
})
if (payloadCase !== 'no-endpoint') {
child.stdin.write(
JSON.stringify({
text:
payloadCase === 'oversized'
? 'x'.repeat(1_000_001)
: payloadCase === 'encoded-oversized'
? '日'.repeat(150_000)
: 'hung-request'
})
)
}
expect(await closed, payloadCase).toBe(0)
clearTimeout(timer)
child.stdin.destroy()
expect(output, payloadCase).toBe('')
expect(requests - before, payloadCase).toBe(payloadCase === 'hung-http' ? 1 : 0)
}
} finally {
server.closeAllConnections()
await new Promise<void>((resolve) => server.close(() => resolve()))
}
}, 15_000)
@@ -0,0 +1,92 @@
import { HOOK_REQUEST_MAX_BYTES } from '../../shared/agent-hook-listener/request-body'
import {
POSIX_HOOK_JSON_STDIN_FIRST_BYTE_TIMEOUT_SECONDS,
POSIX_HOOK_JSON_STDIN_IDLE_TIMEOUT_SECONDS
} from '../agent-hooks/hook-stdin-contract'
// curl reads payload@- before starting its timeout; agy can keep that pipe open.
export const WINDOWS_ANTIGRAVITY_JSON_POST_SCRIPT = String.raw`
const http = require('node:http');
const { StringDecoder } = require('node:string_decoder');
const env = process.env;
const port = Number(env.ORCA_AGENT_HOOK_PORT);
if (!Number.isInteger(port) || port < 1 || port > 65535 || !env.ORCA_AGENT_HOOK_TOKEN || !env.ORCA_PANE_KEY) process.exit(0);
const decoder = new StringDecoder('utf8');
let payload = '';
let finished = false;
let byteLength = 0;
let started = false;
let inString = false;
let escaped = false;
let complete = false;
let invalid = false;
const stack = [];
const maxBytes = ${HOOK_REQUEST_MAX_BYTES};
let idleTimer;
const absoluteTimer = setTimeout(finish, 7000);
function finish() {
if (finished) return;
finished = true;
clearTimeout(idleTimer);
clearTimeout(absoluteTimer);
process.stdin.pause();
payload += decoder.end();
const form = new URLSearchParams();
for (const [name, variable] of [
['paneKey', 'ORCA_PANE_KEY'], ['tabId', 'ORCA_TAB_ID'],
['launchToken', 'ORCA_AGENT_LAUNCH_TOKEN'], ['worktreeId', 'ORCA_WORKTREE_ID'],
['env', 'ORCA_AGENT_HOOK_ENV'], ['version', 'ORCA_AGENT_HOOK_VERSION'],
['hook_event_name', 'ORCA_ANTIGRAVITY_EVENT']
]) form.set(name, env[variable] || '');
form.set('payload', payload.trim() ? payload : '{}');
const body = form.toString();
if (Buffer.byteLength(body) > maxBytes) process.exit(0);
let request;
const exit = () => { if (request) request.destroy(); process.exit(0); };
const postTimer = setTimeout(exit, 1500);
try {
request = http.request({ hostname: '127.0.0.1', port, path: '/hook/antigravity', method: 'POST', headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
} }, response => { response.resume(); response.on('end', () => { clearTimeout(postTimer); exit(); }); });
request.on('error', () => { clearTimeout(postTimer); exit(); });
request.end(body);
} catch { clearTimeout(postTimer); exit(); }
}
function resetIdle(timeout) { clearTimeout(idleTimer); idleTimer = setTimeout(finish, timeout); }
resetIdle(${POSIX_HOOK_JSON_STDIN_FIRST_BYTE_TIMEOUT_SECONDS * 1000});
process.stdin.on('data', chunk => {
if (finished) return;
byteLength += chunk.length;
if (byteLength > maxBytes) process.exit(0);
const text = decoder.write(chunk);
payload += text;
resetIdle(${POSIX_HOOK_JSON_STDIN_IDLE_TIMEOUT_SECONDS * 1000});
for (const character of text) {
if (invalid) break;
if (complete) { if (!/\s/.test(character)) invalid = true; continue; }
if (inString) {
if (escaped) escaped = false;
else if (character === '\\') escaped = true;
else if (character === '"') inString = false;
continue;
}
if (!started && /\s/.test(character)) continue;
if (!started && character !== '{' && character !== '[') { invalid = true; break; }
started = true;
if (character === '"') inString = true;
else if (character === '{') stack.push('}');
else if (character === '[') stack.push(']');
else if (character === '}' || character === ']') {
if (character !== stack.pop()) { invalid = true; break; }
if (!stack.length) complete = true;
}
}
if (complete && !invalid) {
try { JSON.parse(payload); finish(); } catch { invalid = true; }
}
});
process.stdin.on('end', finish);
process.stdin.on('error', finish);
`
@@ -29,7 +29,6 @@ vi.mock('os', async (importOriginal) => {
import { AntigravityHookService } from './hook-service'
import { ANTIGRAVITY_EVENTS, ANTIGRAVITY_PRE_TOOL_USE_DECISION } from './hook-events'
import { getManagedScript, getWindowsWrapperScript } from './hook-script'
import { WINDOWS_HOOK_STDIN_DRAIN_COMMAND } from '../agent-hooks/hook-stdin-contract'
// Why (#9358/#9941): `!` is legal in a Windows path and in a pane key. Under inherited
// delayed expansion cmd eats it out of a percent-expanded curl argument, so bake one into
@@ -105,7 +104,8 @@ function runWrapper(
// Why: `null` abandons stdin instead of closing it — the shape a caller outside an Orca
// pane produces, and the only way to prove the env guard exits before reading (#11549).
stdinPayload: string | null = PAYLOAD,
delayedExpansion: DelayedExpansion = 'off'
delayedExpansion: DelayedExpansion = 'off',
keepStdinOpen = false
): Promise<HookRun> {
return new Promise((resolve, reject) => {
const child = spawn('cmd.exe', [`/v:${delayedExpansion}`, '/d', '/c', wrapperPath], {
@@ -140,7 +140,11 @@ function runWrapper(
// resolves on the child's own terms.
child.stdin.on('error', () => {})
if (stdinPayload !== null) {
child.stdin.end(Buffer.from(stdinPayload, 'utf8'))
if (keepStdinOpen) {
child.stdin.write(Buffer.from(stdinPayload, 'utf8'))
} else {
child.stdin.end(Buffer.from(stdinPayload, 'utf8'))
}
}
})
}
@@ -164,14 +168,8 @@ function expectedStdout(eventName: string): string {
describe('Antigravity Windows hook post command', () => {
it.each(ANTIGRAVITY_EVENTS)('guards missing-core stdin for $eventName', ({ eventName }) => {
const script = getWindowsWrapperScript(eventName)
const drain = script.indexOf(WINDOWS_HOOK_STDIN_DRAIN_COMMAND)
const answer = script.lastIndexOf('echo {}')
expect(drain).toBeGreaterThan(answer)
for (const key of ['ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_PANE_KEY']) {
const guard = script.indexOf(`if "%${key}%"=="" exit /b 0`)
expect(guard, key).toBeGreaterThan(answer)
expect(guard, key).toBeLessThan(drain)
}
expect(script).not.toContain('findstr')
expect(script).toContain('exit /b 0')
})
// Why (#9358/#9941): `%~dp0` carries the hooks path, so an inherited delayed expansion eats
@@ -180,17 +178,13 @@ describe('Antigravity Windows hook post command', () => {
expect(getWindowsWrapperScript(eventName)).toContain('setlocal DisableDelayedExpansion')
})
it('posts through curl.exe rather than a PowerShell interpreter', () => {
it('posts with the owned runtime and keeps payloads off the command line', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const script = getManagedScript('local')
expect(script).not.toMatch(/powershell/i)
expect(script).toContain('"%SystemRoot%\\System32\\curl.exe" -sS -X POST')
expect(script).toContain('http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/antigravity')
expect(script).toContain('--data-urlencode "hook_event_name=%ORCA_ANTIGRAVITY_EVENT%"')
// Why: keep the payload off the command line so multi-KB tool output cannot trip an
// EDR oversized-command-line rule.
expect(script).toContain('--data-urlencode "payload@-"')
const script = getManagedScript('local', 'C:\\Orca!100%\\Orca.exe')
expect(script).not.toMatch(/powershell|curl|payload@-/i)
expect(script).toContain('ORCA_AGENT_HOOK_NODE=C:\\Orca!100%%\\Orca.exe')
expect(script).toContain('"%ORCA_AGENT_HOOK_NODE%" "%~dp0antigravity-hook-post.cjs"')
expect(script).toContain('ELECTRON_RUN_AS_NODE=1')
expect(script).toContain('setlocal DisableDelayedExpansion')
vi.restoreAllMocks()
})
@@ -270,6 +264,34 @@ describe.skipIf(process.platform !== 'win32')('Antigravity Windows hook payload
// Why: ten wrapper launches plus a real install can overrun the default under load.
}, 90_000)
it.each([PAYLOAD, ''])(
'returns and posts when stdin remains open (%#)',
async (input) => {
home = mkdtempSync(join(tmpdir(), 'orca-antigravity-open-'))
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({
ORCA_AGENT_HOOK_PORT: String(listener.port),
ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN,
ORCA_PANE_KEY: PANE_KEY
}),
input,
'on',
true
)
expect(result.timedOut).toBe(false)
expect(result.exitCode).toBe(0)
expect(result.stdout.trim()).toBe('{}')
expect(listener.posts).toHaveLength(1)
expect(listener.posts[0].payload).toBe(input || '{}')
},
15_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.
@@ -295,9 +317,7 @@ describe.skipIf(process.platform !== 'win32')('Antigravity Windows hook payload
expect(result.timedOut).toBe(false)
expect(result.exitCode).toBe(0)
expect(listener.posts).toHaveLength(1)
// Why: curl drops a `--data-urlencode name@-` field entirely when stdin is empty, so the
// event reaches the listener with no `payload` key — not an empty one.
expect(listener.posts[0].payload).toBeNull()
expect(listener.posts[0].payload).toBe('{}')
expect(listener.posts[0].hookEventName).toBe('PreInvocation')
}, 30_000)
@@ -338,9 +358,7 @@ describe.skipIf(process.platform !== 'win32')('Antigravity Windows hook payload
90_000
)
// Why: the guard must not cost the valid path its drain — with the Orca env present the
// fallback still owns stdin, so the agent's payload write completes instead of breaking.
it('still drains a closed payload for every missing-core event inside a pane', async () => {
it('answers every missing-core event inside a pane without waiting for EOF', async () => {
const hooksDir = await installWithoutCore()
const listener = await startHookListener()
server = listener.server
@@ -352,7 +370,13 @@ describe.skipIf(process.platform !== 'win32')('Antigravity Windows hook payload
ORCA_PANE_KEY: PANE_KEY
})
for (const event of ANTIGRAVITY_EVENTS) {
const result = await runWrapper(join(hooksDir, event.windowsWrapperFileName), env)
const result = await runWrapper(
join(hooksDir, event.windowsWrapperFileName),
env,
PAYLOAD,
'on',
true
)
expect(result.timedOut, event.eventName).toBe(false)
expect(result.exitCode, event.eventName).toBe(0)
expect(result.stdout.trim(), event.eventName).toBe(expectedStdout(event.eventName))
@@ -104,6 +104,7 @@ describe('createPtySubprocess', () => {
cwd: 'C:\\repo',
env: {
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
orca_agent_hook_node: 'C:\\Stale\\node.exe',
ORCA_PATH_ROOT: 'C:\\Users\\orca\\AppData\\Local',
PATH: '%orca_path_root%\\agy\\bin;C:\\Windows'
}
@@ -114,6 +115,8 @@ describe('createPtySubprocess', () => {
}
}
expect(spawnMock.mock.calls.at(-1)?.[2].env.ORCA_AGENT_HOOK_NODE).toBe(process.execPath)
expect(spawnMock.mock.calls.at(-1)?.[2].env.orca_agent_hook_node).toBeUndefined()
expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe(
'C:\\Users\\orca\\AppData\\Local\\agy\\bin;C:\\Windows'
)
@@ -228,4 +228,13 @@ export function finalizeDaemonPtyEnvironment(
stripLegacyTerminalShimEnv(env, process.platform)
dropIncoherentCondaActivationEnv(env, process.platform)
stripPiProcessOwnerEnv(env)
// A live daemon pins this runtime across app updates; callers cannot name the host executable.
for (const key of Object.keys(env)) {
if (key.toUpperCase() === 'ORCA_AGENT_HOOK_NODE') {
delete env[key]
}
}
if (process.platform === 'win32') {
env.ORCA_AGENT_HOOK_NODE = process.execPath
}
}
@@ -1,3 +1,5 @@
import { antigravityHookService } from '../antigravity/hook-service'
import { getRelocatedDaemonHost } from '../daemon/daemon-host-relocation'
import { app, ipcMain, powerMonitor, session } from 'electron'
import { is } from '@electron-toolkit/utils'
import os from 'node:os'
@@ -209,6 +211,10 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
// Why captured now: after the dev/E2E override above, and before app.setName('Orca') (whenReady)
// changes how userData resolves on a case-sensitive filesystem. See persistence.ts:20-28.
initDataPath()
antigravityHookService.setWindowsRuntimePathProvider(
() => getRelocatedDaemonHost()?.execPath ?? process.execPath
)
// Why: Electron resolves the macOS safeStorage Keychain service name from the app name before
// ready. Dev pins userData above, so applying its name here cannot shift the captured path.
if (state.devInstanceIdentity && shouldApplyPreReadyAppName(state.devInstanceIdentity)) {