mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
fix(agent-hooks): parse endpoint file and keep hook secrets off argv
The managed shell/cmd hook scripts sourced the endpoint file (`. "$file"` / `call "%file%"`), so any shell/batch written into that path executed with the agent's privileges. They also passed the hook token and the raw payload as curl command-line arguments (readable by other same-UID processes via `ps`/`/proc/<pid>/cmdline`) and did not disable proxying for the loopback POST. Add a shared builder (`agent-hooks/managed-hook-script.ts`) used by every shell/cmd hook: - Parse only the four known ORCA_AGENT_HOOK_* keys from the endpoint file; never execute it. Values are assigned as literals, so a command substitution in the file is stored, not run. - Stream the token into curl as a header from stdin (`-H @-`) and read the payload from a private temp file (`--data-urlencode payload@<file>`), so neither appears on the command line. - Add `--noproxy 127.0.0.1` so a configured http_proxy/ALL_PROXY cannot route the token and prompt off-box. - Validate the port as digits-only; keep the Claude hook silent on stdout. The JS, Python, and PowerShell hooks already parsed the file and sent the token as an in-process header, so they are unchanged. On Windows cmd the token stays on argv (no mktemp; stdin is taken by the payload) — a documented, same-user-only exposure matching the already-ACL'd endpoint file. STA-1284 Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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%"',
|
||||
|
||||
@@ -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<void>((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<string, string>, 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')
|
||||
})
|
||||
})
|
||||
@@ -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/<pid>/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@<file>` 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'
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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":""}')
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user