mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
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:
@@ -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() },
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user