diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 903562b67ed..2ff9a26c569 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -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", diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index ff7602b0689..61ff977e558 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.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( diff --git a/src/main/agent-hooks/windows-hook-post-interpreter.test.ts b/src/main/agent-hooks/windows-hook-post-interpreter.test.ts index 59a409f82d9..4d69b514ce8 100644 --- a/src/main/agent-hooks/windows-hook-post-interpreter.test.ts +++ b/src/main/agent-hooks/windows-hook-post-interpreter.test.ts @@ -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() }, diff --git a/src/main/antigravity/hook-script.ts b/src/main/antigravity/hook-script.ts index 086fc4ede63..3e5767a9726 100644 --- a/src/main/antigravity/hook-script.ts +++ b/src/main/antigravity/hook-script.ts @@ -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') diff --git a/src/main/antigravity/hook-service.test.ts b/src/main/antigravity/hook-service.test.ts index 0142d29faac..d682c69520d 100644 --- a/src/main/antigravity/hook-service.test.ts +++ b/src/main/antigravity/hook-service.test.ts @@ -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 }) diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index 31c41d13f07..fafd9d47b22 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -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 { - 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. diff --git a/src/main/antigravity/windows-hook-json-post.test.ts b/src/main/antigravity/windows-hook-json-post.test.ts new file mode 100644 index 00000000000..17cff6887fc --- /dev/null +++ b/src/main/antigravity/windows-hook-json-post.test.ts @@ -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((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((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((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((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((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((resolve) => server.close(() => resolve())) + } +}, 15_000) diff --git a/src/main/antigravity/windows-hook-json-post.ts b/src/main/antigravity/windows-hook-json-post.ts new file mode 100644 index 00000000000..736ae3a4fd1 --- /dev/null +++ b/src/main/antigravity/windows-hook-json-post.ts @@ -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); +` diff --git a/src/main/antigravity/windows-hook-payload-delivery.test.ts b/src/main/antigravity/windows-hook-payload-delivery.test.ts index 6c9ca08bd27..b3d4ee698da 100644 --- a/src/main/antigravity/windows-hook-payload-delivery.test.ts +++ b/src/main/antigravity/windows-hook-payload-delivery.test.ts @@ -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 { 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)) diff --git a/src/main/daemon/pty-subprocess-env-inheritance.test.ts b/src/main/daemon/pty-subprocess-env-inheritance.test.ts index 636b53d70a1..362b9423c1b 100644 --- a/src/main/daemon/pty-subprocess-env-inheritance.test.ts +++ b/src/main/daemon/pty-subprocess-env-inheritance.test.ts @@ -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' ) diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index a063ffb3dd6..aad4b7ec5e4 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -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 + } } diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 67be830901f..2c1ab5b0743 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -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)) {