diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index a7c4761a0ec..b9ec9ea5d64 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -6,6 +6,7 @@ "../src/main/agent-hooks/installer-utils.ts", "../src/main/agent-hooks/installer-utils-remote.ts", "../src/main/agent-hooks/managed-agent-hook-controls.ts", + "../src/main/agent-hooks/managed-hook-script.ts", "../src/main/amp/hook-service.ts", "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 86bb58607ca..493dac255aa 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -185,8 +185,16 @@ export function buildWindowsAgentHookPostCommand(source: AgentHookSource): strin // Why: Codex runs these hooks inline on every turn. PowerShell startup alone // makes trusted Windows hooks visibly slow, so mirror the POSIX curl path. // Qualify curl so a repo-local curl.exe cannot hijack hook payloads. + // Note: the payload is read from stdin (`payload@-`), off the command line. + // The token stays on argv here because cmd.exe has no mktemp to stage a + // header file and stdin is already taken by the payload; the endpoint.cmd + // file that holds the same token is already ACL'd to the user, so this + // matches the existing same-user exposure rather than widening it. return [ `"%SystemRoot%\\System32\\curl.exe" -sS -X POST "http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}" ^`, + // Why: never let a configured http_proxy/ALL_PROXY route the loopback POST + // (token + payload) off-box. + ' --noproxy 127.0.0.1 ^', ' --connect-timeout 0.5 --max-time 1.5 ^', ' -H "Content-Type: application/x-www-form-urlencoded" ^', ' -H "X-Orca-Agent-Hook-Token: %ORCA_AGENT_HOOK_TOKEN%" ^', @@ -210,6 +218,9 @@ export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): s return [ '"%SystemRoot%\\System32\\curl.exe" -sS -X POST', `"http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}"`, + // Why: never let a configured http_proxy/ALL_PROXY route the loopback POST + // (token + payload) off-box. + '--noproxy 127.0.0.1', '--connect-timeout 0.5 --max-time 1.5', '-H "Content-Type: application/x-www-form-urlencoded"', '-H "X-Orca-Agent-Hook-Token: %ORCA_AGENT_HOOK_TOKEN%"', diff --git a/src/main/agent-hooks/managed-hook-script.test.ts b/src/main/agent-hooks/managed-hook-script.test.ts new file mode 100644 index 00000000000..4f4b5ed6bdb --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script.test.ts @@ -0,0 +1,249 @@ +import { spawn } from 'node:child_process' +import { createServer, type Server } from 'node:http' +import { mkdtempSync, rmSync, writeFileSync, existsSync, readdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + buildPosixManagedHookScript, + buildWindowsEndpointSubroutineLines, + buildWindowsEndpointLoadLine +} from './managed-hook-script' + +describe('buildPosixManagedHookScript — static invariants', () => { + const script = buildPosixManagedHookScript({ source: 'claude' }) + + it('parses the endpoint file instead of sourcing it', () => { + // Sourcing (`. "$file"`) executes arbitrary shell in that path; the managed + // script must never do that. + expect(script).not.toContain('. "$ORCA_AGENT_HOOK_ENDPOINT"') + expect(script).toContain('done < "$ORCA_AGENT_HOOK_ENDPOINT"') + }) + + it('keeps the token off the command line (streamed as a header via stdin)', () => { + expect(script).not.toContain('X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}') + expect(script).toContain('printf \'X-Orca-Agent-Hook-Token: %s\\n\' "$ORCA_AGENT_HOOK_TOKEN" |') + expect(script).toContain('-H @-') + }) + + it('keeps the payload off the command line (read from a temp file)', () => { + expect(script).not.toContain('--data-urlencode "payload=${payload}"') + expect(script).toContain('--data-urlencode "payload@${__orca_payload_file}"') + }) + + it('bypasses any configured proxy for the loopback POST', () => { + expect(script).toContain('--noproxy 127.0.0.1') + }) + + it('validates the port as digits-only before using it in the URL', () => { + expect(script).toContain('""|*[!0-9]*) exit 0 ;;') + }) +}) + +describe('buildWindowsEndpointSubroutineLines — static invariants', () => { + it('parses the endpoint file with for/f rather than call-executing it', () => { + const lines = buildWindowsEndpointSubroutineLines().join('\r\n') + expect(buildWindowsEndpointLoadLine()).toBe('call :__orcaLoadEndpoint') + expect(lines).toContain('for /f "usebackq eol=# tokens=1,* delims=="') + // The load path must never `call` the endpoint file (which would run it). + expect(lines).not.toContain('call "%ORCA_AGENT_HOOK_ENDPOINT%"') + }) +}) + +// Functional round-trip: the generated POSIX script must actually deliver the +// hook to a loopback listener, and must NOT execute the endpoint file. Requires +// /bin/sh + mktemp + curl, so it is POSIX-only. +describe.skipIf(process.platform === 'win32')('buildPosixManagedHookScript — behavior', () => { + let server: Server + let port: number + let dir: string + let scriptPath: string + let received: { + path?: string + token?: string | undefined + payload?: string + hookEventName?: string + } + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'managed-hook-')) + scriptPath = join(dir, 'hook.sh') + writeFileSync(scriptPath, buildPosixManagedHookScript({ source: 'claude' }), { mode: 0o755 }) + received = {} + server = createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', (c) => chunks.push(c)) + req.on('end', () => { + const body = new URLSearchParams(Buffer.concat(chunks).toString()) + received = { + path: req.url, + token: req.headers['x-orca-agent-hook-token'] as string | undefined, + payload: body.get('payload') ?? undefined, + hookEventName: body.get('hook_event_name') ?? undefined + } + res.writeHead(200) + res.end('ok') + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + port = (server.address() as { port: number }).port + }) + + afterEach(() => { + server.close() + rmSync(dir, { recursive: true, force: true }) + }) + + function writeEndpoint(extraLines: string[] = []): string { + const p = join(dir, 'endpoint.env') + writeFileSync( + p, + [ + `ORCA_AGENT_HOOK_PORT=${port}`, + 'ORCA_AGENT_HOOK_TOKEN=tok-DEADBEEF-42', + 'ORCA_AGENT_HOOK_ENV=production', + 'ORCA_AGENT_HOOK_VERSION=1.2.3', + ...extraLines, + '' + ].join('\n') + ) + return p + } + + // Why: drive the script with async spawn, not execFileSync — a synchronous + // child blocks node's event loop so the in-process HTTP listener could never + // accept the loopback POST, and curl would spuriously time out. + function run(env: Record, payload: string): Promise<{ stdout: string }> { + return new Promise((resolve, reject) => { + const child = spawn('/bin/sh', [scriptPath], { env: { ...process.env, ...env } }) + let stdout = '' + child.stdout.on('data', (c) => { + stdout += String(c) + }) + child.on('error', reject) + child.on('close', () => resolve({ stdout })) + child.stdin.end(payload) + }) + } + + it('delivers the payload and streams the token as a header', async () => { + const endpoint = writeEndpoint() + const { stdout } = await run( + { ORCA_AGENT_HOOK_ENDPOINT: endpoint, ORCA_PANE_KEY: 'pane-1' }, + '{"secret":"hi there"}' + ) + expect(received.path).toBe('/hook/claude') + expect(received.token).toBe('tok-DEADBEEF-42') + expect(received.payload).toBe('{"secret":"hi there"}') + // Claude appends UserPromptSubmit hook stdout to the prompt — the claude + // script (no prelude) must print nothing on stdout. + expect(stdout).toBe('') + }) + + it('prints nothing on stdout even when it cannot create the temp file', async () => { + const endpoint = writeEndpoint() + // Point TMPDIR at a non-writable/nonexistent dir so mktemp fails; the hook + // must fail open silently, never leaking mktemp's error into Claude's prompt. + const { stdout } = await run( + { ORCA_AGENT_HOOK_ENDPOINT: endpoint, ORCA_PANE_KEY: 'pane-x', TMPDIR: join(dir, 'nope') }, + '{"a":1}' + ) + expect(stdout).toBe('') + expect(received.path).toBeUndefined() + }) + + it('parses the endpoint file without executing shell inside it', async () => { + // If the file were sourced, this command substitution would create a marker. + const marker = join(dir, 'PWNED') + const endpoint = writeEndpoint([ + `ORCA_AGENT_HOOK_ENV=$(touch ${marker})`, + `\`touch ${marker}2\`` + ]) + await run({ ORCA_AGENT_HOOK_ENDPOINT: endpoint, ORCA_PANE_KEY: 'pane-2' }, '{"x":1}') + expect(existsSync(marker)).toBe(false) + expect(existsSync(`${marker}2`)).toBe(false) + // Junk lines are ignored, but the known keys still drive a successful post. + expect(received.path).toBe('/hook/claude') + }) + + it('lets the endpoint file override stale PTY coordinates', async () => { + const endpoint = writeEndpoint() + await run( + { + ORCA_AGENT_HOOK_ENDPOINT: endpoint, + ORCA_AGENT_HOOK_PORT: '9', + ORCA_AGENT_HOOK_TOKEN: 'STALE', + ORCA_PANE_KEY: 'pane-3' + }, + '{"y":2}' + ) + expect(received.path).toBe('/hook/claude') + expect(received.token).toBe('tok-DEADBEEF-42') + }) + + it('does not post when the port is non-numeric', async () => { + await run( + { ORCA_AGENT_HOOK_PORT: '12ab', ORCA_AGENT_HOOK_TOKEN: 't', ORCA_PANE_KEY: 'p' }, + '{"z":3}' + ) + expect(received.path).toBeUndefined() + }) + + it('does not post when the payload is empty', async () => { + const endpoint = writeEndpoint() + await run({ ORCA_AGENT_HOOK_ENDPOINT: endpoint, ORCA_PANE_KEY: 'pane-5' }, '') + expect(received.path).toBeUndefined() + }) + + it('bypasses a configured http_proxy for the loopback POST', async () => { + const endpoint = writeEndpoint() + await run( + { + ORCA_AGENT_HOOK_ENDPOINT: endpoint, + ORCA_PANE_KEY: 'pane-6', + http_proxy: 'http://127.0.0.1:1', + HTTPS_PROXY: 'http://127.0.0.1:1', + ALL_PROXY: 'http://127.0.0.1:1' + }, + '{"p":6}' + ) + expect(received.path).toBe('/hook/claude') + }) + + it('does not leave the payload temp file behind', async () => { + const endpoint = writeEndpoint() + // Pin TMPDIR so the mktemp file lands in our dir and the assertion is exact. + await run( + { ORCA_AGENT_HOOK_ENDPOINT: endpoint, ORCA_PANE_KEY: 'pane-7', TMPDIR: dir }, + '{"q":7}' + ) + const leftovers = readdirSync(dir).filter((n) => n.startsWith('orca-agent-hook.')) + expect(leftovers).toEqual([]) + }) + + // Exercise the Antigravity-shaped options: an extra data field plus a literal + // fallback when the agent sends no stdin. + it('substitutes the empty-payload literal and sends extra fields', async () => { + writeFileSync( + scriptPath, + buildPosixManagedHookScript({ + source: 'antigravity', + extraDataFields: [{ name: 'hook_event_name', envVar: 'ORCA_ANTIGRAVITY_EVENT' }], + emptyPayload: { literal: '{}' } + }), + { mode: 0o755 } + ) + const endpoint = writeEndpoint() + await run( + { + ORCA_AGENT_HOOK_ENDPOINT: endpoint, + ORCA_PANE_KEY: 'pane-8', + ORCA_ANTIGRAVITY_EVENT: 'Stop' + }, + '' + ) + expect(received.path).toBe('/hook/antigravity') + expect(received.payload).toBe('{}') + expect(received.hookEventName).toBe('Stop') + }) +}) diff --git a/src/main/agent-hooks/managed-hook-script.ts b/src/main/agent-hooks/managed-hook-script.ts new file mode 100644 index 00000000000..90e33ff5646 --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script.ts @@ -0,0 +1,176 @@ +import type { AgentHookSource } from '../../shared/agent-hook-relay' + +// Shared builders for the managed agent-hook scripts. Centralizing the +// security-critical steps (reading the endpoint file, and posting to the +// loopback listener) keeps a single audited implementation instead of the same +// shell/batch repeated across every agent's hook service. +// +// Two properties this module guarantees for every generated script: +// 1. The endpoint file is *parsed*, never executed. Earlier scripts sourced +// it (`. "$file"` / `call "%file%"`), so any shell/batch written into that +// path ran with the agent's privileges. We only ever copy the four known +// `ORCA_AGENT_HOOK_*` values out of it. +// 2. The hook token and the raw payload never appear on the process command +// line. `ps`/`/proc//cmdline` are readable by other same-UID (and, on +// many systems, cross-UID) processes, so the token went to a header read +// from stdin and the payload to a private temp file read via `@file`. +// `--noproxy 127.0.0.1` keeps the loopback POST from being redirected through a +// configured `http_proxy`/`ALL_PROXY`, which would send the token and payload +// off-box. + +export type PosixEmptyPayloadBehavior = 'exit' | { readonly literal: string } + +export type PosixManagedHookScriptOptions = { + readonly source: AgentHookSource + /** Lines emitted right after the shebang, before the endpoint is parsed. + * Used for agents that must print a JSON response to stdout (Gemini, + * Antigravity, Copilot) or bail early (Devin import guard) regardless of + * whether the post ultimately fires. */ + readonly preludeLines?: readonly string[] + /** Extra form fields appended to the curl post, e.g. Antigravity's + * `hook_event_name`. `envVar` is read verbatim as `${envVar}`. */ + readonly extraDataFields?: readonly { readonly name: string; readonly envVar: string }[] + /** What to do when the agent sent no stdin. Default `exit` (skip the post); + * Antigravity substitutes `{}` so a status row still appears. */ + readonly emptyPayload?: PosixEmptyPayloadBehavior +} + +// Why: `< "$file"` (not `cat "$file" |`) keeps the loop in the current shell so +// the assignments survive it. Each value is copied as a literal — no `eval`, no +// command substitution on the assignment RHS — so `TOKEN=$(rm -rf ~)` in the +// file is stored as that literal string, never executed. The `set ` strip and +// trailing-CR strip tolerate an `endpoint.cmd` (Windows) file that reached a +// POSIX shell via a cross-platform userData copy. +export function buildPosixEndpointParseLines(): string[] { + return [ + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + " __orca_cr=$(printf '\\r')", + ' while IFS= read -r __orca_line || [ -n "$__orca_line" ]; do', + ' __orca_line=${__orca_line%"$__orca_cr"}', + ' case "$__orca_line" in', + ' "set "*) __orca_line=${__orca_line#set } ;;', + ' esac', + ' case "$__orca_line" in', + ' ORCA_AGENT_HOOK_PORT=*) ORCA_AGENT_HOOK_PORT=${__orca_line#*=} ;;', + ' ORCA_AGENT_HOOK_TOKEN=*) ORCA_AGENT_HOOK_TOKEN=${__orca_line#*=} ;;', + ' ORCA_AGENT_HOOK_ENV=*) ORCA_AGENT_HOOK_ENV=${__orca_line#*=} ;;', + ' ORCA_AGENT_HOOK_VERSION=*) ORCA_AGENT_HOOK_VERSION=${__orca_line#*=} ;;', + ' esac', + ' done < "$ORCA_AGENT_HOOK_ENDPOINT"', + 'fi' + ] +} + +// Why: guards run after the parse so refreshed coordinates are in effect. The +// port is validated as digits-only (defense-in-depth against a malformed +// endpoint file) before it is interpolated into the request URL. +export function buildPosixHookGuardLines(): string[] { + return [ + 'case "$ORCA_AGENT_HOOK_PORT" in', + ' ""|*[!0-9]*) exit 0 ;;', + 'esac', + 'if [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' exit 0', + 'fi' + ] +} + +// Why: capture the payload into a private temp file (mktemp is 0600) and post +// it with `--data-urlencode payload@` so the raw prompt never lands on +// argv. This also keeps tens-of-KB tool output off the curl command line, which +// avoids EDR command-line-length false positives (the concern #4475 fixed via a +// stdin pipe). We use a temp file rather than that stdin pipe because stdin is +// needed for the token: it is streamed into curl as a header via `-H @-`, so the +// secret stays off argv AND off disk. Non-secret identifiers +// (paneKey/tabId/worktreeId/…) stay as argv fields — they are not sensitive and +// keeping them there avoids escaping filesystem paths into the temp file. +export function buildPosixSecureCurlPostLines(options: PosixManagedHookScriptOptions): string[] { + const { source, extraDataFields = [], emptyPayload = 'exit' } = options + const emptyLines = + emptyPayload === 'exit' + ? ['if [ ! -s "$__orca_payload_file" ]; then', ' exit 0', 'fi'] + : [ + 'if [ ! -s "$__orca_payload_file" ]; then', + ` printf '%s' '${emptyPayload.literal}' > "$__orca_payload_file"`, + 'fi' + ] + return [ + // Why: fail open (skip the status post) if the temp file cannot be created, + // matching the rest of the hook's best-effort behavior. Suppress mktemp's + // stderr so a failure never leaks into hook output — Claude appends + // UserPromptSubmit hook stdout to the prompt, and other agents parse stdout. + '__orca_payload_file=$(mktemp "${TMPDIR:-/tmp}/orca-agent-hook.XXXXXX" 2>/dev/null) || exit 0', + 'trap \'rm -f "$__orca_payload_file"\' EXIT', + 'cat > "$__orca_payload_file"', + ...emptyLines, + 'printf \'X-Orca-Agent-Hook-Token: %s\\n\' "$ORCA_AGENT_HOOK_TOKEN" | ' + + `curl -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`, + ' --noproxy 127.0.0.1 \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H @- \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ...extraDataFields.map((field) => ` --data-urlencode "${field.name}=\${${field.envVar}}" \\`), + ' --data-urlencode "payload@${__orca_payload_file}" >/dev/null 2>&1 || true' + ] +} + +/** Full POSIX `.sh` managed hook script for a simple agent (shebang, optional + * prelude, endpoint parse, guards, secure post). Agents with bespoke recovery + * logic (e.g. Command Code) compose the pieces above directly instead. */ +export function buildPosixManagedHookScript(options: PosixManagedHookScriptOptions): string { + return [ + '#!/bin/sh', + ...(options.preludeLines ?? []), + ...buildPosixEndpointParseLines(), + ...buildPosixHookGuardLines(), + ...buildPosixSecureCurlPostLines(options), + 'exit 0', + '' + ].join('\n') +} + +// ─── Windows (cmd) endpoint parsing ───────────────────────────────────────── + +/** Main-body line that loads endpoint coordinates by *parsing* the file (never + * `call`-ing it). Pair with `buildWindowsEndpointSubroutineLines()`, placed + * after the script's final `exit /b`. */ +export function buildWindowsEndpointLoadLine(): string { + return 'call :__orcaLoadEndpoint' +} + +// Why: `for /f` reads the file line by line and hands each key/value to a +// subroutine that assigns only the four known variables — the file is parsed, +// not executed, so batch written into endpoint.cmd never runs. Both the +// `set `-prefixed (endpoint.cmd) and bare (cross-copied endpoint.env) shapes +// are matched. `%~1`/`%~2` strip the quotes `for /f` leaves on tokens. +// `:__orcaParseEndpointFile` takes an explicit file path so agents that search +// multiple candidate endpoint files (e.g. Command Code) can reuse it. +export function buildWindowsEndpointSubroutineLines(): string[] { + return [ + ':__orcaLoadEndpoint', + 'if not defined ORCA_AGENT_HOOK_ENDPOINT exit /b 0', + 'call :__orcaParseEndpointFile "%ORCA_AGENT_HOOK_ENDPOINT%"', + 'exit /b 0', + ':__orcaParseEndpointFile', + 'if not exist "%~1" exit /b 0', + 'for /f "usebackq eol=# tokens=1,* delims==" %%A in ("%~1") do call :__orcaSetEndpoint "%%A" "%%B"', + 'exit /b 0', + ':__orcaSetEndpoint', + 'set "__orca_k=%~1"', + 'if /i "%__orca_k%"=="set ORCA_AGENT_HOOK_PORT" set "ORCA_AGENT_HOOK_PORT=%~2"', + 'if /i "%__orca_k%"=="ORCA_AGENT_HOOK_PORT" set "ORCA_AGENT_HOOK_PORT=%~2"', + 'if /i "%__orca_k%"=="set ORCA_AGENT_HOOK_TOKEN" set "ORCA_AGENT_HOOK_TOKEN=%~2"', + 'if /i "%__orca_k%"=="ORCA_AGENT_HOOK_TOKEN" set "ORCA_AGENT_HOOK_TOKEN=%~2"', + 'if /i "%__orca_k%"=="set ORCA_AGENT_HOOK_ENV" set "ORCA_AGENT_HOOK_ENV=%~2"', + 'if /i "%__orca_k%"=="ORCA_AGENT_HOOK_ENV" set "ORCA_AGENT_HOOK_ENV=%~2"', + 'if /i "%__orca_k%"=="set ORCA_AGENT_HOOK_VERSION" set "ORCA_AGENT_HOOK_VERSION=%~2"', + 'if /i "%__orca_k%"=="ORCA_AGENT_HOOK_VERSION" set "ORCA_AGENT_HOOK_VERSION=%~2"', + 'exit /b 0' + ] +} diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 46ff181a73d..118f1619237 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines -- Why: this suite exercises the full hook HTTP surface (Claude/Codex/Gemini parsing, transcript chunked scan, paneKey dispatch) and keeping the scenarios co-located avoids fixture drift across files. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { execFileSync } from 'node:child_process' import { existsSync, mkdirSync, @@ -5663,7 +5662,7 @@ describe('Endpoint file lifecycle', () => { } }) - it('endpoint file contents are re-parseable by /bin/sh', async () => { + it('endpoint file stays a clean KEY=VALUE shape (no shell metacharacters)', async () => { if (process.platform === 'win32') { return } @@ -5672,14 +5671,15 @@ describe('Endpoint file lifecycle', () => { try { const filePath = server.endpointFilePath! const expectedPort = server.buildPtyEnv().ORCA_AGENT_HOOK_PORT - // Why: sources the file in a subshell and echoes the resulting env var, - // exactly as the managed hook script does at runtime. If the file shape - // ever drifts from `KEY=VALUE` (e.g. someone adds shell metacharacters - // without quoting), this test catches it before users do. - const out = execFileSync('/bin/sh', ['-c', `. "${filePath}" && echo "$ORCA_AGENT_HOOK_PORT"`]) - .toString() - .trim() - expect(out).toBe(expectedPort) + // Why: the managed hook script parses this file (it does not source it), + // reading only the four known ORCA_AGENT_HOOK_* keys. This asserts the + // writer keeps each line as a bare `KEY=VALUE` so that line-based parse + // extracts the value verbatim — catching drift before users do. + const contents = readFileSync(filePath, 'utf-8') + const portLine = contents + .split(/\r?\n/) + .find((line) => line.startsWith('ORCA_AGENT_HOOK_PORT=')) + expect(portLine).toBe(`ORCA_AGENT_HOOK_PORT=${expectedPort}`) } finally { server.stop() } diff --git a/src/main/antigravity/hook-service.test.ts b/src/main/antigravity/hook-service.test.ts index 67750cedffb..bad21937c6b 100644 --- a/src/main/antigravity/hook-service.test.ts +++ b/src/main/antigravity/hook-service.test.ts @@ -93,14 +93,11 @@ describe('AntigravityHookService', () => { expect(script).not.toContain('[string]::IsNullOrWhiteSpace($inputData)) { exit 0 }') } else { expect(script).toContain('hook_event_name=${ORCA_ANTIGRAVITY_EVENT}') - expect(script).toContain('payload=$(cat)') - expect(script).toContain("payload='{}'") - expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi') - // Why: payload is piped to curl via stdin (`payload@-`) so it never lands - // on the curl command line (EDR oversized-command-line false positive). - expect(script).toContain('printf \'%s\' "$payload" | curl') - expect(script).toContain('--data-urlencode "payload@-"') - expect(script).not.toContain('--data-urlencode "payload=${payload}"') + // Payload delivered from a private temp file (off argv), with `{}` + // substituted when the agent sent no stdin. Endpoint file parsed, not sourced. + expect(script).toContain('--data-urlencode "payload@${__orca_payload_file}"') + expect(script).toContain("printf '%s' '{}' > \"$__orca_payload_file\"") + expect(script).not.toContain('. "$ORCA_AGENT_HOOK_ENDPOINT"') } expect(script).toContain('{"decision":""}') }) diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index c8e30632a16..9eabcf93671 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -19,6 +19,11 @@ import { type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' +import { + buildPosixManagedHookScript, + buildWindowsEndpointLoadLine, + buildWindowsEndpointSubroutineLines +} from '../agent-hooks/managed-hook-script' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -91,60 +96,39 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ') else (', ' echo {}', ')', - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + // Why: reload the endpoint file's live coordinates after an Orca restart. + // Parsed, never executed, so untrusted content in that path cannot run. + buildWindowsEndpointLoadLine(), 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', buildWindowsAntigravityHookPostCommand(), 'exit /b 0', + ...buildWindowsEndpointSubroutineLines(), '' ].join('\r\n') } - return [ - '#!/bin/sh', - 'case "$ORCA_ANTIGRAVITY_EVENT" in', - ' Stop)', - ' printf \'{"decision":""}\\n\'', - ' ;;', - ' *)', - // Why: Antigravity accepts an empty JSON object for passive status hooks; - // returning allow/ask/deny from PreToolUse would change the user's tool - // permission policy. - ' printf "{}\\n"', - ' ;;', - 'esac', - 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', - 'fi', - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' exit 0', - 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - // Why: some Antigravity hook events can arrive without stdin. Still post - // the event name so Orca shows a status row instead of silently dropping it. - " payload='{}'", - 'fi', - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/antigravity" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "hook_event_name=${ORCA_ANTIGRAVITY_EVENT}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', - 'exit 0', - '' - ].join('\n') + return buildPosixManagedHookScript({ + source: 'antigravity', + preludeLines: [ + 'case "$ORCA_ANTIGRAVITY_EVENT" in', + ' Stop)', + ' printf \'{"decision":""}\\n\'', + ' ;;', + ' *)', + // Why: Antigravity accepts an empty JSON object for passive status hooks; + // returning allow/ask/deny from PreToolUse would change the user's tool + // permission policy. + ' printf "{}\\n"', + ' ;;', + 'esac' + ], + extraDataFields: [{ name: 'hook_event_name', envVar: 'ORCA_ANTIGRAVITY_EVENT' }], + // Why: some Antigravity hook events arrive without stdin. Still post the + // event name so Orca shows a status row instead of silently dropping it. + emptyPayload: { literal: '{}' } + }) } function getWindowsWrapperScript(eventName: string): string { diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index ed77f48e6e0..1167dfdacab 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -269,12 +269,14 @@ describe('ClaudeHookService.installRemote', () => { const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh') expect(script).toContain('#!/bin/sh') expect(script).toContain('DEVIN_PROJECT_DIR') - // Why: payload is piped to curl via stdin (`payload@-`) so it never lands - // on the curl command line (EDR oversized-command-line false positive), - // matching the Windows curl.exe hook post. - expect(script).toContain('printf \'%s\' "$payload" | curl') - expect(script).toContain('--data-urlencode "payload@-"') + // Why: payload posts from a private temp file and the token streams in as a + // header via stdin, so neither the prompt nor the token lands on the curl + // command line (also avoids EDR oversized-command-line false positives). + // The endpoint file is parsed, never sourced. + expect(script).toContain('--data-urlencode "payload@${__orca_payload_file}"') + expect(script).toContain('-H @-') expect(script).not.toContain('--data-urlencode "payload=${payload}"') + expect(script).not.toContain('. "$ORCA_AGENT_HOOK_ENDPOINT"') expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755) }) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 758daec3890..db96918bc1f 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -6,6 +6,11 @@ import { writeHooksJson, writeManagedScript } from '../agent-hooks/installer-utils' +import { + buildPosixManagedHookScript, + buildWindowsEndpointLoadLine, + buildWindowsEndpointSubroutineLines +} from '../agent-hooks/managed-hook-script' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -55,11 +60,10 @@ function getManagedScript( : []), // Why: the endpoint file holds the *live* port/token for this Orca // install. A PTY that survived an Orca restart has stale PORT/TOKEN - // baked into its env from the old instance — loading `endpoint.cmd` - // (`set KEY=VALUE` lines) via `call` refreshes them so the hook - // reaches the current server. Falls through to PTY env if the file - // is missing (first run / pre-endpoint-file / running outside Orca). - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + // baked into its env from the old instance — reloading `endpoint.cmd` + // refreshes them so the hook reaches the current server. Parsed, never + // executed, so untrusted content in that path cannot run. + buildWindowsEndpointLoadLine(), 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', @@ -70,13 +74,14 @@ function getManagedScript( // curl works the same here as for the POSIX/Codex hooks. buildWindowsAgentHookCurlPostCommand('claude'), 'exit /b 0', + ...buildWindowsEndpointSubroutineLines(), '' ].join('\r\n') } - return [ - '#!/bin/sh', - ...(options.skipWhenDevinImportsClaude + return buildPosixManagedHookScript({ + source: 'claude', + preludeLines: options.skipWhenDevinImportsClaude ? [ // Why: Devin imports .claude hooks by default. Skip Orca's managed // Claude hook there so status posts stay attributed to Devin. @@ -84,52 +89,8 @@ function getManagedScript( ' exit 0', 'fi' ] - : []), - // Why: the endpoint file holds the *live* port/token for this Orca - // install. PTYs that survive an Orca restart have stale PORT/TOKEN - // baked into their env from the old instance — sourcing the file here - // lets us reach the new server. Falls back to PTY env if the file is - // missing (first-run / pre-endpoint-file scripts / running outside Orca). - // Why: suppress stderr on the `.` builtin. A TOCTOU race (endpoint unlinked - // between the `[ -r ]` test and the source) or a malformed line (e.g. CRLF - // bled in from a cross-platform userData copy) would otherwise print a - // parse error that agent transcripts could surface. Stale coords → dead - // port → silent-fail is the documented fail-open path anyway — the env-var - // guards below handle the empty PORT/TOKEN case — so swallowing the noise - // here is strictly better than leaking shell errors into the hook output. - // `|| :` defends against an eventual `set -e` in an outer script context - // (not present today) aborting the hook on a parse error. - 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', - 'fi', - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' exit 0', - 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', - // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX - // shell is not safe once a path contains quotes or newlines. Post the raw - // hook payload plus metadata as form fields and let the receiver parse it. - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', - 'exit 0', - '' - ].join('\n') + : [] + }) } export class ClaudeHookService { diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 0b01d65554f..abc4782a6e5 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -19,6 +19,11 @@ import { type HookCommandConfig, type HookDefinition } from '../agent-hooks/installer-utils' +import { + buildPosixManagedHookScript, + buildWindowsEndpointLoadLine, + buildWindowsEndpointSubroutineLines +} from '../agent-hooks/managed-hook-script' import { readHooksJsonRemote, readTextFileRemote, @@ -680,55 +685,21 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { '@echo off', 'setlocal', // Why: see claude/hook-service.ts for rationale. The endpoint file holds - // the live port/token for this Orca install; sourcing it here lets a + // the live port/token for this Orca install; reloading it here lets a // surviving PTY reach the current server even though its env points at - // the prior Orca's coordinates. - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + // the prior Orca's coordinates. Parsed, never executed. + buildWindowsEndpointLoadLine(), 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', buildWindowsAgentHookCurlPostCommand('codex'), 'exit /b 0', + ...buildWindowsEndpointSubroutineLines(), '' ].join('\r\n') } - return [ - '#!/bin/sh', - // Why: see claude/hook-service.ts for rationale. Sourcing refreshes - // PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps - // reporting after a restart. - 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', - 'fi', - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' exit 0', - 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', - // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX - // shell is not safe once a path contains quotes or newlines. Post the raw - // hook payload plus metadata as form fields and let the receiver parse it. - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', - 'exit 0', - '' - ].join('\n') + return buildPosixManagedHookScript({ source: 'codex' }) } export class CodexHookService { diff --git a/src/main/command-code/command-code-managed-script.ts b/src/main/command-code/command-code-managed-script.ts index cc625c6054e..5aff9d4b131 100644 --- a/src/main/command-code/command-code-managed-script.ts +++ b/src/main/command-code/command-code-managed-script.ts @@ -1,4 +1,8 @@ import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils' +import { + buildPosixSecureCurlPostLines, + buildWindowsEndpointSubroutineLines +} from '../agent-hooks/managed-hook-script' export type CommandCodeManagedScriptTarget = 'local' | 'posix' @@ -9,7 +13,11 @@ export function buildCommandCodeManagedScript( return [ '@echo off', 'setlocal', - 'if "%ORCA_AGENT_HOOK_PORT%"=="" if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + // Why: the endpoint file(s) are parsed, never executed, so untrusted + // content in that path cannot run. Command Code sanitizes the hook env, so + // when the primary endpoint is missing we still search %APPDATA% for the + // endpoint.cmd whose port matches the recovered loopback port. + 'if "%ORCA_AGENT_HOOK_PORT%"=="" call :__orcaLoadEndpoint', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" if not "%ORCA_AGENT_HOOK_PORT%"=="" call :sourceEndpointByPort', 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', @@ -23,8 +31,9 @@ export function buildCommandCodeManagedScript( 'exit /b 0', ':maybeSourceEndpoint', 'if not "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', - 'for /f "tokens=2 delims==" %%P in (\'findstr /b /c:"set ORCA_AGENT_HOOK_PORT=" "%~1" 2^>nul\') do if "%%P"=="%ORCA_AGENT_HOOK_PORT%" call "%~1" 2>nul', + 'for /f "tokens=2 delims==" %%P in (\'findstr /b /c:"set ORCA_AGENT_HOOK_PORT=" "%~1" 2^>nul\') do if "%%P"=="%ORCA_AGENT_HOOK_PORT%" call :__orcaParseEndpointFile "%~1"', 'exit /b 0', + ...buildWindowsEndpointSubroutineLines(), '' ].join('\r\n') } @@ -116,25 +125,7 @@ export function buildCommandCodeManagedScript( 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/command-code" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ...buildPosixSecureCurlPostLines({ source: 'command-code' }), 'exit 0', '' ].join('\n') diff --git a/src/main/copilot/hook-service.ts b/src/main/copilot/hook-service.ts index a61588ebff5..b7d91090e46 100644 --- a/src/main/copilot/hook-service.ts +++ b/src/main/copilot/hook-service.ts @@ -16,6 +16,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { buildPosixManagedHookScript } from '../agent-hooks/managed-hook-script' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -151,39 +152,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ].join('\r\n') } - return [ - '#!/bin/sh', - "printf '{}\\n'", - // Why: Copilot consumes stdout for some hooks, so stdout is emitted before + return buildPosixManagedHookScript({ + source: 'copilot', + // Why: Copilot consumes stdout for some hooks, so `{}` is emitted before // endpoint refresh, stdin parsing, or the network POST can fail. - 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', - 'fi', - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' exit 0', - 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/copilot" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', - 'exit 0', - '' - ].join('\n') + preludeLines: ["printf '{}\\n'"], + extraDataFields: [{ name: 'hookEventName', envVar: 'ORCA_COPILOT_HOOK_EVENT' }] + }) } export class CopilotHookService { diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index d0460904452..bcf7a5618d9 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -77,12 +77,10 @@ describe('CursorHookService', () => { if (process.platform === 'win32') { expect(script).toContain('%SystemRoot%\\System32\\curl.exe') } else { - // Why: payload is piped to curl via stdin (`payload@-`) so it never lands - // on the curl command line (EDR oversized-command-line false positive). - expect(script).toContain('payload=$(cat)') - expect(script).toContain('printf \'%s\' "$payload" | curl') - expect(script).toContain('--data-urlencode "payload@-"') - expect(script).not.toContain('--data-urlencode "payload=${payload}"') + // Payload goes to a private temp file (off argv); token is streamed as a + // header via `-H @-`; the endpoint file is parsed, never sourced. + expect(script).toContain('--data-urlencode "payload@${__orca_payload_file}"') + expect(script).not.toContain('. "$ORCA_AGENT_HOOK_ENDPOINT"') } }) diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 9c5d19e8fa4..199ca53ec5a 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -15,6 +15,11 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { + buildPosixManagedHookScript, + buildWindowsEndpointLoadLine, + buildWindowsEndpointSubroutineLines +} from '../agent-hooks/managed-hook-script' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -72,55 +77,21 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { '@echo off', 'setlocal', // Why: see claude/hook-service.ts for rationale. The endpoint file holds - // the live port/token for this Orca install; sourcing it here lets a + // the live port/token for this Orca install; reloading it here lets a // surviving PTY reach the current server even though its env points at - // the prior Orca's coordinates. - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + // the prior Orca's coordinates. Parsed, never executed. + buildWindowsEndpointLoadLine(), 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', buildWindowsAgentHookPostCommand('cursor'), 'exit /b 0', + ...buildWindowsEndpointSubroutineLines(), '' ].join('\r\n') } - return [ - '#!/bin/sh', - // Why: see claude/hook-service.ts for rationale. Sourcing refreshes - // PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps - // reporting after a restart. - 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', - 'fi', - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' exit 0', - 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', - // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX - // shell is not safe once a path contains quotes or newlines. Post the raw - // hook payload plus metadata as form fields and let the receiver parse it. - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', - 'exit 0', - '' - ].join('\n') + return buildPosixManagedHookScript({ source: 'cursor' }) } export class CursorHookService { diff --git a/src/main/devin/hook-service.test.ts b/src/main/devin/hook-service.test.ts index 6bc105ffddc..850b1ee8ed4 100644 --- a/src/main/devin/hook-service.test.ts +++ b/src/main/devin/hook-service.test.ts @@ -64,11 +64,13 @@ describe('DevinHookService', () => { } const script = readFileSync(getDevinManagedScriptPath(), 'utf8') expect(script).toContain('/hook/devin') - // Why: payload is piped to curl via stdin (`payload@-`) so it never lands - // on the curl command line (EDR oversized-command-line false positive). - expect(script).toContain('printf \'%s\' "$payload" | curl') - expect(script).toContain('--data-urlencode "payload@-"') + // Why: payload posts from a private temp file and the token streams in as a + // header via stdin, so neither lands on the curl command line (also avoids + // EDR oversized-command-line false positives). Endpoint file parsed, not sourced. + expect(script).toContain('--data-urlencode "payload@${__orca_payload_file}"') + expect(script).toContain('-H @-') expect(script).not.toContain('--data-urlencode "payload=${payload}"') + expect(script).not.toContain('. "$ORCA_AGENT_HOOK_ENDPOINT"') }) it('preserves unrelated keys in Devin config when installing hooks', () => { diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index b0d63b96ebd..4d002cf6524 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -5,6 +5,11 @@ import { writeHooksJson, writeManagedScript } from '../agent-hooks/installer-utils' +import { + buildPosixManagedHookScript, + buildWindowsEndpointLoadLine, + buildWindowsEndpointSubroutineLines +} from '../agent-hooks/managed-hook-script' import { readTextFileRemote, writeHooksJsonRemote, @@ -36,67 +41,21 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'setlocal', // Why: the endpoint file holds the *live* port/token for this Orca // install. A PTY that survived an Orca restart has stale PORT/TOKEN - // baked into its env from the old instance — loading `endpoint.cmd` - // (`set KEY=VALUE` lines) via `call` refreshes them so the hook - // reaches the current server. Falls through to PTY env if the file - // is missing (first run / pre-endpoint-file / running outside Orca). - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + // baked into its env from the old instance — reloading `endpoint.cmd` + // refreshes them so the hook reaches the current server. Parsed, never + // executed, so untrusted content in that path cannot run. + buildWindowsEndpointLoadLine(), 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', buildWindowsAgentHookPostCommand('devin'), 'exit /b 0', + ...buildWindowsEndpointSubroutineLines(), '' ].join('\r\n') } - return [ - '#!/bin/sh', - // Why: the endpoint file holds the *live* port/token for this Orca - // install. PTYs that survive an Orca restart have stale PORT/TOKEN - // baked into their env from the old instance — sourcing the file here - // lets us reach the new server. Falls back to PTY env if the file is - // missing (first-run / pre-endpoint-file scripts / running outside Orca). - // Why: suppress stderr on the `.` builtin. A TOCTOU race (endpoint unlinked - // between the `[ -r ]` test and the source) or a malformed line (e.g. CRLF - // bled in from a cross-platform userData copy) would otherwise print a - // parse error that agent transcripts could surface. Stale coords → dead - // port → silent-fail is the documented fail-open path anyway — the env-var - // guards below handle the empty PORT/TOKEN case — so swallowing the noise - // here is strictly better than leaking shell errors into the hook output. - // `|| :` defends against an eventual `set -e` in an outer script context - // (not present today) aborting the hook on a parse error. - 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', - 'fi', - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' exit 0', - 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', - // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX - // shell is not safe once a path contains quotes or newlines. Post the raw - // hook payload plus metadata as form fields and let the receiver parse it. - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/devin" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', - 'exit 0', - '' - ].join('\n') + return buildPosixManagedHookScript({ source: 'devin' }) } export class DevinHookService { diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 920e079989d..3ff02b71217 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -14,6 +14,11 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { + buildPosixManagedHookScript, + buildWindowsEndpointLoadLine, + buildWindowsEndpointSubroutineLines +} from '../agent-hooks/managed-hook-script' // Why: SessionStart is installed (not just listened for) so that resuming a // droid session via `droid --resume` resets the per-pane prompt/tool caches @@ -69,46 +74,20 @@ function getManagedScript(): string { return [ '@echo off', 'setlocal', - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + // Why: see claude/hook-service.ts. The endpoint file is parsed (never + // executed) to refresh live coordinates after an Orca restart. + buildWindowsEndpointLoadLine(), 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', buildWindowsAgentHookPostCommand('droid'), 'exit /b 0', + ...buildWindowsEndpointSubroutineLines(), '' ].join('\r\n') } - return [ - '#!/bin/sh', - 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', - 'fi', - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' exit 0', - 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/droid" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', - 'exit 0', - '' - ].join('\n') + return buildPosixManagedHookScript({ source: 'droid' }) } export class DroidHookService { diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 423df12f8fb..500d674cc7f 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -16,6 +16,11 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { + buildPosixManagedHookScript, + buildWindowsEndpointLoadLine, + buildWindowsEndpointSubroutineLines +} from '../agent-hooks/managed-hook-script' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -61,59 +66,27 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // output, even if the env-var guards below cause an early exit. 'echo {}', // Why: see claude/hook-service.ts for rationale. The endpoint file holds - // the live port/token for this Orca install; sourcing it here lets a + // the live port/token for this Orca install; reloading it here lets a // surviving PTY reach the current server even though its env points at - // the prior Orca's coordinates. - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + // the prior Orca's coordinates. Parsed, never executed. + buildWindowsEndpointLoadLine(), 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', buildWindowsAgentHookPostCommand('gemini'), 'exit /b 0', + ...buildWindowsEndpointSubroutineLines(), '' ].join('\r\n') } - return [ - '#!/bin/sh', + return buildPosixManagedHookScript({ + source: 'gemini', // Why: Gemini expects valid JSON on stdout even when the hook has nothing // to return. Emit `{}` first so the agent never stalls parsing our output, // even if the env-var guards below cause an early exit. - 'printf "{}\\n"', - // Why: see claude/hook-service.ts for rationale. Sourcing refreshes - // PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps - // reporting after a restart. - 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', - 'fi', - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' exit 0', - 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', - // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX - // shell is not safe once a path contains quotes or newlines. Post the raw - // hook payload plus metadata as form fields and let the receiver parse it. - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', - 'exit 0', - '' - ].join('\n') + preludeLines: ['printf "{}\\n"'] + }) } export class GeminiHookService { diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 23cc2eeaf5f..4edfe6c1e7b 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -76,12 +76,10 @@ describe('GrokHookService', () => { if (process.platform === 'win32') { expect(script).toContain('%SystemRoot%\\System32\\curl.exe') } else { - // Why: payload is piped to curl via stdin (`payload@-`) so it never lands - // on the curl command line (EDR oversized-command-line false positive). - expect(script).toContain('payload=$(cat)') - expect(script).toContain('printf \'%s\' "$payload" | curl') - expect(script).toContain('--data-urlencode "payload@-"') - expect(script).not.toContain('--data-urlencode "payload=${payload}"') + // Payload goes to a private temp file (off argv); the endpoint file is + // parsed, never sourced. + expect(script).toContain('--data-urlencode "payload@${__orca_payload_file}"') + expect(script).not.toContain('. "$ORCA_AGENT_HOOK_ENDPOINT"') } }) diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index 6fbbacadbc7..60259b3dea5 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -15,6 +15,11 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { + buildPosixManagedHookScript, + buildWindowsEndpointLoadLine, + buildWindowsEndpointSubroutineLines +} from '../agent-hooks/managed-hook-script' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -67,46 +72,20 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '@echo off', 'setlocal', - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + // Why: see claude/hook-service.ts. The endpoint file is parsed (never + // executed) to refresh live coordinates after an Orca restart. + buildWindowsEndpointLoadLine(), 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', buildWindowsAgentHookPostCommand('grok'), 'exit /b 0', + ...buildWindowsEndpointSubroutineLines(), '' ].join('\r\n') } - return [ - '#!/bin/sh', - 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', - 'fi', - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' exit 0', - 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', - 'exit 0', - '' - ].join('\n') + return buildPosixManagedHookScript({ source: 'grok' }) } function buildInstalledConfig( diff --git a/src/main/kimi/hook-service.test.ts b/src/main/kimi/hook-service.test.ts index 24a77444e19..aeb188b2b8c 100644 --- a/src/main/kimi/hook-service.test.ts +++ b/src/main/kimi/hook-service.test.ts @@ -55,11 +55,13 @@ describe('KimiHookService', () => { // The managed script must exist and POST to the Kimi hook endpoint. const script = readFileSync(scriptPath(), 'utf-8') expect(script).toContain('/hook/kimi') - // Why: payload is piped to curl via stdin (`payload@-`) so it never lands - // on the curl command line (EDR oversized-command-line false positive). - expect(script).toContain('printf \'%s\' "$payload" | curl') - expect(script).toContain('--data-urlencode "payload@-"') + // Why: payload posts from a private temp file and the token streams in as a + // header via stdin, so neither lands on the curl command line (also avoids + // EDR oversized-command-line false positives). Endpoint file parsed, not sourced. + expect(script).toContain('--data-urlencode "payload@${__orca_payload_file}"') + expect(script).toContain('-H @-') expect(script).not.toContain('--data-urlencode "payload=${payload}"') + expect(script).not.toContain('. "$ORCA_AGENT_HOOK_ENDPOINT"') // The command Kimi runs points at the managed script via sh. expect(config).toContain('agent-hooks/kimi-hook.sh') }) diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 459c0994e83..ed06bdf11fd 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -18,6 +18,7 @@ import { wrapPosixHookCommand, writeManagedScript } from '../agent-hooks/installer-utils' +import { buildPosixManagedHookScript } from '../agent-hooks/managed-hook-script' import { readTextFileRemote, writeManagedScriptRemote, @@ -57,41 +58,9 @@ function getManagedCommand(scriptPath: string): string { } function getManagedScript(): string { - return [ - '#!/bin/sh', - // Why: refresh PORT/TOKEN/ENV/VERSION from the current Orca install so a PTY - // that survived an Orca restart still reaches the live listener. See - // claude/hook-service.ts for the full rationale. - 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', - 'fi', - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' exit 0', - 'fi', - 'payload=$(cat)', - 'if [ -z "$payload" ]; then', - ' exit 0', - 'fi', - // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX - // shell is not safe once a path contains quotes or newlines. Post the raw - // hook payload plus metadata as form fields and let the receiver parse it. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/kimi" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', - 'exit 0', - '' - ].join('\n') + // Kimi always runs a POSIX `.sh` body (Git Bash even on Windows), so the + // shared builder covers every platform here. + return buildPosixManagedHookScript({ source: 'kimi' }) } // Returns the file text, '' when the config does not exist yet (Kimi creates it