fix(agent-hooks): drain stdin before hook script early exits so agents never hit EPIPE (#8430)
* Fix hook scripts to drain stdin before any early-exit path Generated agent hook scripts and missing-script launchers could exit successfully before consuming the payload written to their stdin, leaving the writer with a broken pipe (EPIPE/ERROR_BROKEN_PIPE) once the reader closed early. Capture stdin (or drain it via a shared epilogue/fast-path guard) before any whole-script success exit across all POSIX, batch, PowerShell, and Git Bash launcher variants, and add a cross-agent lifecycle test suite plus a live Electron verification script to guard the contract going forward. * Harden hook scripts against unreadable managed scripts and add a Claude/ - Extend the POSIX launcher guard to also require `[ -r ]`, not just `-f`/`-x`, so an executable-but-unreadable managed script still drains stdin instead of erroring or silently misbehaving. - Add a verifier case (`verifyClaudeDevinSkip`) that spins up a local HTTP server and confirms the Claude hook never forwards a request that Devin already imported, catching accidental double-forwarding. - Update installer-utils tests and stdin-lifecycle docs to match the new readable-file guard and the added verification case. * Fix hook-launcher verification to derive script paths from the installed Extract the quoted path from the launcher's `if [ -f '...'` clause instead of reconstructing it via join(home, ...), so missing/failing-script test cases can't silently fall through to the real script if the install layout changes. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
parent
8303d955e8
commit
c3ab805d12
|
|
@ -0,0 +1,394 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import {
|
||||
accessSync,
|
||||
chmodSync,
|
||||
constants as fsConstants,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const MANAGED_SCRIPTS = [
|
||||
['antigravity-hook.sh', 'antigravity'],
|
||||
['claude-hook.sh', 'claude'],
|
||||
['codex-hook.sh', 'codex'],
|
||||
['command-code-hook.sh', 'command-code'],
|
||||
['copilot-hook.sh', 'copilot'],
|
||||
['cursor-hook.sh', 'cursor'],
|
||||
['devin-hook.sh', 'devin'],
|
||||
['droid-hook.sh', 'droid'],
|
||||
['gemini-hook.sh', 'gemini'],
|
||||
['grok-hook.sh', 'grok'],
|
||||
['kimi-hook.sh', 'kimi'],
|
||||
['openclaude-hook.sh', 'claude']
|
||||
]
|
||||
|
||||
const REQUIRED_JSON_STDOUT = new Set(['antigravity-hook.sh', 'copilot-hook.sh', 'gemini-hook.sh'])
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = { home: process.env.HOME ?? '', minMtime: 0 }
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index]
|
||||
if (value === '--home') {
|
||||
result.home = argv[index + 1] ?? ''
|
||||
index += 1
|
||||
} else if (value === '--min-mtime') {
|
||||
result.minMtime = Number(argv[index + 1] ?? 0)
|
||||
index += 1
|
||||
} else {
|
||||
throw new Error(['Unknown argument: ', value].join(''))
|
||||
}
|
||||
}
|
||||
if (!result.home) {
|
||||
throw new Error('Pass --home or set HOME to the isolated Electron home directory')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function withoutOrcaEnvironment(extra = {}) {
|
||||
return {
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_'))),
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
function runShell(command, payload, env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('/bin/sh', ['-c', command], {
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
const stdout = []
|
||||
const stderr = []
|
||||
const stdinErrors = []
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(['Timed out running: ', command.slice(0, 120)].join('')))
|
||||
}, 10_000)
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk))
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk))
|
||||
child.stdin.on('error', (error) => stdinErrors.push(error))
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
child.on('close', (exitCode) => {
|
||||
clearTimeout(timeout)
|
||||
resolve({
|
||||
exitCode,
|
||||
stdinErrors,
|
||||
stdout: Buffer.concat(stdout).toString('utf8'),
|
||||
stderr: Buffer.concat(stderr).toString('utf8')
|
||||
})
|
||||
})
|
||||
child.stdin.end(payload)
|
||||
})
|
||||
}
|
||||
|
||||
function assertSuccessfulWrite(result, label) {
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
[label, ' exited ', String(result.exitCode), ': ', result.stderr.slice(0, 500)].join('')
|
||||
)
|
||||
}
|
||||
if (result.stdinErrors.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
label,
|
||||
' produced stdin errors: ',
|
||||
result.stdinErrors.map((error) => error.message).join(', ')
|
||||
].join('')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertProtocolStdout(fileName, stdout) {
|
||||
if (!REQUIRED_JSON_STDOUT.has(fileName)) {
|
||||
return
|
||||
}
|
||||
const firstLine = stdout.trim().split(/\r?\n/, 1)[0]
|
||||
try {
|
||||
JSON.parse(firstLine)
|
||||
} catch {
|
||||
throw new Error([fileName, ' did not emit protocol JSON: ', stdout.slice(0, 200)].join(''))
|
||||
}
|
||||
}
|
||||
|
||||
function readGeneratedScripts(home, minMtime) {
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
return MANAGED_SCRIPTS.map(([fileName, source]) => {
|
||||
const path = join(hooksDir, fileName)
|
||||
const stats = statSync(path)
|
||||
if (!stats.isFile()) {
|
||||
throw new Error([fileName, ' is not a regular file'].join(''))
|
||||
}
|
||||
try {
|
||||
accessSync(path, fsConstants.R_OK | fsConstants.X_OK)
|
||||
} catch {
|
||||
throw new Error([fileName, ' is not readable and executable'].join(''))
|
||||
}
|
||||
if (minMtime > 0 && stats.mtimeMs < minMtime) {
|
||||
throw new Error([fileName, ' predates the Electron launch'].join(''))
|
||||
}
|
||||
const body = readFileSync(path, 'utf8')
|
||||
const captureIndex = body.indexOf('payload=$(cat)')
|
||||
const firstExitIndex = body.indexOf('exit 0')
|
||||
if (captureIndex < 0 || firstExitIndex <= captureIndex) {
|
||||
throw new Error([fileName, ' can exit before capturing stdin'].join(''))
|
||||
}
|
||||
return { body, fileName, path, source }
|
||||
})
|
||||
}
|
||||
|
||||
function findStrings(value, matches = []) {
|
||||
if (typeof value === 'string') {
|
||||
matches.push(value)
|
||||
return matches
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value) {
|
||||
findStrings(child, matches)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const child of Object.values(value)) {
|
||||
findStrings(child, matches)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
function nextRequest(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
server.removeListener('request', onRequest)
|
||||
reject(new Error('Generated hook did not reach the loopback server'))
|
||||
}, 8_000)
|
||||
const onRequest = (request, response) => {
|
||||
const chunks = []
|
||||
request.on('data', (chunk) => chunks.push(chunk))
|
||||
request.on('end', () => {
|
||||
clearTimeout(timeout)
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
response.end('{}')
|
||||
resolve({
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
headers: request.headers,
|
||||
url: request.url
|
||||
})
|
||||
})
|
||||
}
|
||||
server.once('request', onRequest)
|
||||
})
|
||||
}
|
||||
|
||||
async function verifyNoOpWrites(scripts, home, payload) {
|
||||
const commandCodeBin = mkdtempSync(join(tmpdir(), 'orca-hook-command-code-bin-'))
|
||||
symlinkSync('/bin/cat', join(commandCodeBin, 'cat'))
|
||||
try {
|
||||
for (const script of scripts) {
|
||||
const path =
|
||||
script.fileName === 'command-code-hook.sh'
|
||||
? commandCodeBin
|
||||
: (process.env.PATH ?? '/usr/bin:/bin')
|
||||
const result = await runShell(
|
||||
['/bin/sh ', JSON.stringify(script.path)].join(''),
|
||||
payload,
|
||||
withoutOrcaEnvironment({
|
||||
HOME: home,
|
||||
PATH: path,
|
||||
ORCA_AGENT_HOOK_ENDPOINT: ''
|
||||
})
|
||||
)
|
||||
assertSuccessfulWrite(result, [script.fileName, ' no-op'].join(''))
|
||||
assertProtocolStdout(script.fileName, result.stdout)
|
||||
}
|
||||
} finally {
|
||||
rmSync(commandCodeBin, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyClaudeDevinSkip(scripts, home, payload) {
|
||||
const claude = scripts.find((script) => script.fileName === 'claude-hook.sh')
|
||||
let unexpectedRequests = 0
|
||||
const server = createServer((_request, response) => {
|
||||
unexpectedRequests += 1
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
response.end('{}')
|
||||
})
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
try {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Claude skip verifier did not receive a TCP port')
|
||||
}
|
||||
const result = await runShell(
|
||||
['/bin/sh ', JSON.stringify(claude.path)].join(''),
|
||||
payload,
|
||||
withoutOrcaEnvironment({
|
||||
DEVIN_PROJECT_DIR: join(home, 'devin-project'),
|
||||
HOME: home,
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '',
|
||||
ORCA_AGENT_HOOK_PORT: String(address.port),
|
||||
ORCA_AGENT_HOOK_TOKEN: 'electron-verification-token',
|
||||
ORCA_PANE_KEY: 'electron-verification-pane'
|
||||
})
|
||||
)
|
||||
assertSuccessfulWrite(result, 'Claude Devin-import skip')
|
||||
if (unexpectedRequests !== 0) {
|
||||
throw new Error('Claude forwarded a hook imported by Devin')
|
||||
}
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyForwarding(scripts, home, payload) {
|
||||
const server = createServer()
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
try {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Loopback verifier did not receive a TCP port')
|
||||
}
|
||||
for (const script of scripts) {
|
||||
const requestPromise = nextRequest(server)
|
||||
const result = await runShell(
|
||||
['/bin/sh ', JSON.stringify(script.path)].join(''),
|
||||
payload,
|
||||
withoutOrcaEnvironment({
|
||||
HOME: home,
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '',
|
||||
ORCA_AGENT_HOOK_PORT: String(address.port),
|
||||
ORCA_AGENT_HOOK_TOKEN: 'electron-verification-token',
|
||||
ORCA_PANE_KEY: 'electron-verification-pane',
|
||||
ORCA_TAB_ID: 'electron-verification-tab',
|
||||
ORCA_WORKTREE_ID: 'electron-verification-worktree',
|
||||
ORCA_AGENT_HOOK_ENV: 'test',
|
||||
ORCA_AGENT_HOOK_VERSION: '1',
|
||||
ORCA_ANTIGRAVITY_EVENT: 'PostInvocation',
|
||||
ORCA_COPILOT_HOOK_EVENT: 'PostToolUse'
|
||||
})
|
||||
)
|
||||
assertSuccessfulWrite(result, [script.fileName, ' forwarding'].join(''))
|
||||
assertProtocolStdout(script.fileName, result.stdout)
|
||||
const request = await requestPromise
|
||||
const form = new URLSearchParams(request.body)
|
||||
if (request.url !== ['/hook/', script.source].join('')) {
|
||||
throw new Error(
|
||||
[script.fileName, ' posted to ', String(request.url), ' instead of ', script.source].join(
|
||||
''
|
||||
)
|
||||
)
|
||||
}
|
||||
if (request.headers['x-orca-agent-hook-token'] !== 'electron-verification-token') {
|
||||
throw new Error([script.fileName, ' lost the hook token header'].join(''))
|
||||
}
|
||||
if (form.get('payload') !== payload) {
|
||||
throw new Error([script.fileName, ' changed the forwarded payload'].join(''))
|
||||
}
|
||||
if (form.get('paneKey') !== 'electron-verification-pane') {
|
||||
throw new Error([script.fileName, ' changed the forwarded pane key'].join(''))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
// Why: rewrite from the path embedded in the installed command, not a
|
||||
// reconstructed join(home, ...). That way missing/failing-script cases cannot
|
||||
// silently re-run the real script if the install layout changes.
|
||||
function rewriteLauncherScriptPath(command, nextPath) {
|
||||
const match = /if \[ -f '([^']+)'/.exec(command)
|
||||
if (!match) {
|
||||
throw new Error('Installed launcher command did not reference a quoted script path')
|
||||
}
|
||||
return command.replaceAll(match[1], nextPath)
|
||||
}
|
||||
|
||||
async function verifyInstalledLauncher(home, payload) {
|
||||
const settingsPath = join(home, '.claude', 'settings.json')
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
const command = findStrings(settings).find(
|
||||
(value) => value.includes('claude-hook.sh') && value.includes('if [ -f ')
|
||||
)
|
||||
if (!command || !command.includes('] && [ -r ') || !command.includes('else cat >/dev/null')) {
|
||||
throw new Error('Electron did not install the guarded Claude launcher')
|
||||
}
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'orca-hook-launcher-'))
|
||||
try {
|
||||
const missingPath = join(scratch, 'missing-hook.sh')
|
||||
const missingResult = await runShell(
|
||||
rewriteLauncherScriptPath(command, missingPath),
|
||||
payload,
|
||||
withoutOrcaEnvironment({ HOME: home })
|
||||
)
|
||||
assertSuccessfulWrite(missingResult, 'installed missing-script launcher')
|
||||
|
||||
const failingPath = join(scratch, 'failing-hook.sh')
|
||||
writeFileSync(failingPath, '#!/bin/sh\ncat >/dev/null\nexit 7\n', 'utf8')
|
||||
chmodSync(failingPath, 0o755)
|
||||
const failingResult = await runShell(
|
||||
rewriteLauncherScriptPath(command, failingPath),
|
||||
payload,
|
||||
withoutOrcaEnvironment({ HOME: home })
|
||||
)
|
||||
if (failingResult.exitCode !== 7 || failingResult.stdinErrors.length > 0) {
|
||||
throw new Error('Installed launcher did not preserve a running script failure')
|
||||
}
|
||||
} finally {
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const payload = JSON.stringify({
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'shell',
|
||||
tool_output: 'x'.repeat(1_200_000)
|
||||
})
|
||||
const scripts = readGeneratedScripts(args.home, args.minMtime)
|
||||
await verifyNoOpWrites(scripts, args.home, payload)
|
||||
await verifyClaudeDevinSkip(scripts, args.home, payload)
|
||||
await verifyForwarding(scripts, args.home, payload)
|
||||
await verifyInstalledLauncher(args.home, payload)
|
||||
process.stdout.write(
|
||||
[
|
||||
JSON.stringify(
|
||||
{
|
||||
forwardingPayloadBytes: Buffer.byteLength(payload),
|
||||
claudeDevinSkipCases: 1,
|
||||
launcherCases: 2,
|
||||
noOpScripts: scripts.length,
|
||||
forwardedScripts: scripts.length,
|
||||
status: 'passed'
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'\n'
|
||||
].join('')
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
"include": [
|
||||
"../src/cli/**/*",
|
||||
"../src/shared/**/*",
|
||||
"../src/main/agent-hooks/hook-stdin-contract.ts",
|
||||
"../src/main/agent-hooks/installer-utils.ts",
|
||||
"../src/main/agent-hooks/installer-utils-remote.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
# Agent Hook Stdin Lifecycle
|
||||
|
||||
## Problem
|
||||
|
||||
Orca installs agent hooks in global agent configuration, so a managed hook can
|
||||
run in an Orca pane, an SSH/WSL runtime, a detached session, or an ordinary
|
||||
terminal with no Orca environment. Hook runners write an event payload to the
|
||||
child process's stdin after spawn.
|
||||
|
||||
The generated scripts currently inspect Orca environment variables before they
|
||||
read stdin. When a guard exits first, the hook runner can still be writing to a
|
||||
pipe whose reader has closed. POSIX reports `EPIPE`; Windows reports
|
||||
`ERROR_BROKEN_PIPE`. The same failure exists when a managed launcher finds that
|
||||
its script was removed or is no longer executable.
|
||||
|
||||
This is an ownership bug rather than an agent-specific parsing bug. Every
|
||||
process that accepts a hook payload owns the read end until it reaches EOF,
|
||||
including processes that decide the event is irrelevant.
|
||||
|
||||
## Goals
|
||||
|
||||
- Make successful hook no-ops consume stdin to EOF on macOS, Linux, Windows,
|
||||
WSL, and SSH hosts.
|
||||
- Encode stdin ownership once per platform instead of copying ad hoc drains
|
||||
into every early-exit branch.
|
||||
- Preserve payload bytes, hook output, transport timeouts, exit-code behavior,
|
||||
and provider-specific event metadata on the forwarding path.
|
||||
- Cover every generated script and missing-script launcher with behavioral
|
||||
regression tests.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not redesign hook configuration schemas, endpoint discovery, relay HTTP
|
||||
payloads, or event selection.
|
||||
- Do not make hook payloads unbounded; existing agent and config timeouts remain
|
||||
the outer lifecycle bound.
|
||||
- Do not suppress failures from a script that exists and actually runs. Only a
|
||||
deliberate no-op path exits successfully after consuming stdin.
|
||||
- Do not change plugin-based integrations that do not receive piped hook stdin.
|
||||
|
||||
## Contract
|
||||
|
||||
1. A generated hook script consumes its input exactly once.
|
||||
2. No whole-script success exit may occur before that consumption completes.
|
||||
3. A launcher that declines to start a missing, unreadable, or non-executable
|
||||
managed script becomes the stdin owner and drains to EOF before returning
|
||||
success.
|
||||
4. A launcher propagates the exit code of a managed script that was started.
|
||||
5. Output required by an agent protocol may be emitted before stdin is read,
|
||||
but the process must still retain the read end until EOF.
|
||||
6. Drain commands are platform-qualified where the platform searches the
|
||||
working directory implicitly.
|
||||
|
||||
## Design
|
||||
|
||||
### POSIX generated scripts
|
||||
|
||||
Capture stdin near the start of the generated script, after any protocol output
|
||||
that must be immediate and before endpoint refresh, environment guards, or
|
||||
provider-specific skips:
|
||||
|
||||
```sh
|
||||
payload=$(cat)
|
||||
if [ -z "$payload" ]; then
|
||||
exit 0
|
||||
fi
|
||||
```
|
||||
|
||||
Antigravity is the one semantic exception: events without payload still post an
|
||||
empty object, so its shared capture policy maps empty input to `{}` instead of
|
||||
exiting. Claude's Devin-import skip happens after capture. Command Code captures
|
||||
before ancestor/endpoint recovery so its comparatively expensive discovery
|
||||
cannot leave the writer blocked.
|
||||
|
||||
The common payload-capture fragments live in
|
||||
`src/main/agent-hooks/hook-stdin-contract.ts`. Templates choose the required or
|
||||
empty-object policy instead of spelling the lifecycle independently.
|
||||
|
||||
### Windows batch generated scripts
|
||||
|
||||
Batch scripts stream stdin directly into system `curl.exe`; buffering arbitrary
|
||||
JSON in an environment variable would corrupt metacharacters and hit size
|
||||
limits. Their posting path therefore remains streaming.
|
||||
|
||||
All environment guard failures jump to one epilogue:
|
||||
|
||||
```bat
|
||||
if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin
|
||||
...
|
||||
exit /b 0
|
||||
:orca_agent_hook_drain_stdin
|
||||
"%SystemRoot%\System32\more.com" >nul 2>nul
|
||||
exit /b 0
|
||||
```
|
||||
|
||||
`more.com` is qualified because Windows searches the current working directory
|
||||
for executables. Shared guard and epilogue builders keep labels and commands
|
||||
identical across templates. Command Code's existing endpoint-discovery labels
|
||||
remain subroutines and the drain epilogue is placed after them.
|
||||
|
||||
### PowerShell generated scripts
|
||||
|
||||
PowerShell captures with `[Console]::In.ReadToEnd()` before endpoint and
|
||||
environment guards. Copilot then parses the captured value only on the posting
|
||||
path. This mirrors POSIX ownership without starting an additional process.
|
||||
|
||||
### Managed launchers
|
||||
|
||||
- POSIX `/bin/sh` launchers require a regular, readable, executable file. The
|
||||
rejected path drains with `cat`; the started-script branch keeps propagating
|
||||
status.
|
||||
- Encoded PowerShell launchers use `Test-Path -PathType Leaf`. A missing script
|
||||
calls `[Console]::In.ReadToEnd()` and exits zero; an existing script preserves
|
||||
`$LASTEXITCODE`.
|
||||
- Codex's cmd.exe fast path remains PowerShell-free. It rejects missing paths
|
||||
and directories, using the same qualified `more.com` drain for both.
|
||||
- Claude's Git Bash fast path uses a POSIX file guard and drain while continuing
|
||||
to execute the `.cmd` directly rather than interpreting it as shell source.
|
||||
- Agent-specific direct launchers, including Antigravity event wrappers and
|
||||
Copilot's PowerShell-file command, adopt the same missing-file behavior.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```text
|
||||
hook runner spawns command
|
||||
-> runner writes payload and closes stdin
|
||||
-> launcher starts managed script
|
||||
-> script consumes payload
|
||||
-> refreshes endpoint / evaluates guards
|
||||
-> posts or exits zero
|
||||
-> OR launcher cannot start script
|
||||
-> launcher drains payload
|
||||
-> exits zero
|
||||
```
|
||||
|
||||
Local, WSL, and SSH installs serialize the same POSIX template. Windows local
|
||||
installs use the batch or PowerShell template. No host assumes another host's
|
||||
path syntax or shell.
|
||||
|
||||
## Failure Semantics
|
||||
|
||||
- Missing Orca environment: consume input, exit zero, emit only protocol-required
|
||||
output.
|
||||
- Empty payload: consume EOF, then follow the agent's existing empty-event rule.
|
||||
- Missing/unreadable/non-executable script: launcher consumes input and exits
|
||||
zero.
|
||||
- Endpoint parse/read failure: preserve the existing fail-open behavior after
|
||||
stdin ownership has been satisfied.
|
||||
- Existing script returns nonzero: propagate its status; do not drain again or
|
||||
disguise the script failure.
|
||||
- Hook runner never closes stdin: the existing config-level timeout terminates
|
||||
the hook. Reading to EOF does not introduce an unbounded lifecycle beyond that
|
||||
already required by normal payload parsing.
|
||||
|
||||
## Verification
|
||||
|
||||
Add one cross-agent lifecycle suite rather than per-service string-position
|
||||
assertions.
|
||||
|
||||
- Generate every SSH-compatible POSIX managed script, spawn it with Orca
|
||||
environment removed, write a payload larger than pipe buffers, and assert:
|
||||
exit zero, no stdin error, and required protocol output remains valid.
|
||||
- Exercise the Claude Devin skip independently because it is a second no-op
|
||||
condition before endpoint forwarding.
|
||||
- Exercise POSIX, encoded PowerShell, cmd.exe, and Git Bash missing-script
|
||||
launchers with a large payload and verify zero write errors.
|
||||
- On Windows CI, install and execute every local batch/PowerShell managed script
|
||||
with missing Orca environment and a large payload.
|
||||
- Keep structural assertions for shared batch guard/epilogue generation, but do
|
||||
not use substring placement as the primary regression gate.
|
||||
- Preserve existing successful-post, timeout, quoting, SSH install, WSL, and
|
||||
nonzero-exit propagation tests.
|
||||
|
||||
For a macOS smoke test, launch the real Electron app with an isolated `HOME`
|
||||
and `ORCA_DEV_USER_DATA_PATH`, record the launch time, then run:
|
||||
|
||||
```sh
|
||||
node config/scripts/verify-agent-hook-stdin-lifecycle.mjs \
|
||||
--home "$ISOLATED_HOME" \
|
||||
--min-mtime "$LAUNCH_START_MS"
|
||||
```
|
||||
|
||||
The verifier rejects stale files, then exercises the 12 POSIX scripts written
|
||||
by that Electron launch with a payload larger than pipe buffers. It covers
|
||||
successful no-ops, loopback forwarding without payload changes, required JSON
|
||||
stdout, Claude's Devin skip, and both branches of the installed missing-script
|
||||
launcher.
|
||||
|
||||
## Rollout And Compatibility
|
||||
|
||||
The generated scripts are rewritten by existing install/update flows, so no
|
||||
config migration is needed. Commands use POSIX `sh` primitives, Windows inbox
|
||||
executables, and PowerShell features available on supported Windows releases.
|
||||
No Git behavior or provider-specific review behavior changes.
|
||||
|
||||
The change should ship atomically across templates and launchers. A partial
|
||||
rollout would leave global hooks with platform- or agent-dependent pipe safety,
|
||||
which is the inconsistency this design removes.
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import type { SFTPWrapper } from 'ssh2'
|
||||
|
||||
export type AgentHookMemoryFileSystem = {
|
||||
files: Map<string, string>
|
||||
dirs: Set<string>
|
||||
modes: Map<string, number>
|
||||
}
|
||||
|
||||
export function createAgentHookMemorySftp(initialFiles: Record<string, string> = {}): {
|
||||
sftp: SFTPWrapper
|
||||
fs: AgentHookMemoryFileSystem
|
||||
} {
|
||||
const fs: AgentHookMemoryFileSystem = {
|
||||
files: new Map(Object.entries(initialFiles)),
|
||||
dirs: new Set(['/']),
|
||||
modes: new Map()
|
||||
}
|
||||
const missing = (path: string): { code: number; message: string } => ({
|
||||
code: 2,
|
||||
message: `ENOENT ${path}`
|
||||
})
|
||||
const sftp = {
|
||||
readFile: (path: string, _encoding: string, done: (error: unknown, data?: string) => void) => {
|
||||
const data = fs.files.get(path)
|
||||
if (data === undefined) {
|
||||
done(missing(path))
|
||||
return
|
||||
}
|
||||
done(null, data)
|
||||
},
|
||||
writeFile: (
|
||||
path: string,
|
||||
content: string,
|
||||
options: string | { mode?: number },
|
||||
done: (error: unknown) => void
|
||||
) => {
|
||||
fs.files.set(path, content)
|
||||
if (typeof options !== 'string' && options.mode !== undefined) {
|
||||
fs.modes.set(path, options.mode)
|
||||
}
|
||||
done(null)
|
||||
},
|
||||
rename: (source: string, target: string, done: (error: unknown) => void) => {
|
||||
const content = fs.files.get(source)
|
||||
if (content === undefined) {
|
||||
done(missing(source))
|
||||
return
|
||||
}
|
||||
fs.files.set(target, content)
|
||||
fs.files.delete(source)
|
||||
const mode = fs.modes.get(source)
|
||||
if (mode !== undefined) {
|
||||
fs.modes.set(target, mode)
|
||||
fs.modes.delete(source)
|
||||
}
|
||||
done(null)
|
||||
},
|
||||
unlink: (path: string, done: (error: unknown) => void) => {
|
||||
fs.files.delete(path)
|
||||
fs.modes.delete(path)
|
||||
done(null)
|
||||
},
|
||||
chmod: (path: string, mode: number, done: (error: unknown) => void) => {
|
||||
fs.modes.set(path, mode)
|
||||
done(null)
|
||||
},
|
||||
stat: (path: string, done: (error: unknown, stats?: { mode: number }) => void) => {
|
||||
if (!fs.files.has(path)) {
|
||||
done(missing(path))
|
||||
return
|
||||
}
|
||||
done(null, { mode: fs.modes.get(path) ?? 0o100644 })
|
||||
},
|
||||
readdir: (path: string, done: (error: unknown, entries?: { filename: string }[]) => void) => {
|
||||
if (!fs.dirs.has(path)) {
|
||||
done(missing(path))
|
||||
return
|
||||
}
|
||||
done(null, [])
|
||||
},
|
||||
mkdir: (path: string, done: (error: unknown) => void) => {
|
||||
fs.dirs.add(path)
|
||||
done(null)
|
||||
}
|
||||
} as unknown as SFTPWrapper
|
||||
return { sftp, fs }
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
export type PosixHookEmptyPayloadPolicy = 'exit' | 'empty-object'
|
||||
|
||||
export const POSIX_HOOK_STDIN_DRAIN_COMMAND = 'cat >/dev/null 2>&1 || :'
|
||||
|
||||
// Why: every POSIX hook must own stdin before any no-op exit; sharing this
|
||||
// prelude prevents agent templates from inventing different drain semantics.
|
||||
export function buildPosixHookPayloadCapture(
|
||||
emptyPayloadPolicy: PosixHookEmptyPayloadPolicy = 'exit'
|
||||
): string[] {
|
||||
const emptyPayloadLines =
|
||||
emptyPayloadPolicy === 'empty-object' ? [" payload='{}'"] : [' exit 0']
|
||||
return ['payload=$(cat)', 'if [ -z "$payload" ]; then', ...emptyPayloadLines, 'fi']
|
||||
}
|
||||
|
||||
export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin'
|
||||
export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = '"%SystemRoot%\\System32\\more.com" >nul 2>nul'
|
||||
|
||||
// Why: batch payloads stream directly to curl and cannot be buffered safely in
|
||||
// environment variables, so guard failures share one EOF-draining epilogue.
|
||||
export function buildWindowsHookEnvironmentGuardLines(): string[] {
|
||||
const drainTarget = `goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`
|
||||
return [
|
||||
`if "%ORCA_AGENT_HOOK_PORT%"=="" ${drainTarget}`,
|
||||
`if "%ORCA_AGENT_HOOK_TOKEN%"=="" ${drainTarget}`,
|
||||
`if "%ORCA_PANE_KEY%"=="" ${drainTarget}`
|
||||
]
|
||||
}
|
||||
|
||||
export function buildWindowsHookStdinDrainEpilogue(): string[] {
|
||||
return [
|
||||
`:${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`,
|
||||
// Why: qualify the inbox reader because Windows searches the worktree for
|
||||
// executables before PATH and hook payloads must not reach repo-local code.
|
||||
WINDOWS_HOOK_STDIN_DRAIN_COMMAND,
|
||||
'exit /b 0'
|
||||
]
|
||||
}
|
||||
|
|
@ -160,10 +160,8 @@ describe('createManagedCommandMatcher', () => {
|
|||
})
|
||||
|
||||
it('matches the guarded launcher form so wrapped commands sweep correctly', () => {
|
||||
// Why: wrapPosixHookCommand wraps the launcher in `if [ -x ... ]; then ...; fi`
|
||||
// so a stale entry no-ops instead of returning exit 127. The sweep on
|
||||
// install() must still recognize the guarded form as managed, otherwise
|
||||
// repeated installs would accumulate one guarded + one unguarded entry.
|
||||
// Why: older guarded launchers used a single `-x` check. The sweep must
|
||||
// still recognize them or reinstalling would retain a stale duplicate.
|
||||
expect(
|
||||
match(
|
||||
'if [ -x "/Users/alice/Library/Application Support/Orca/agent-hooks/claude-hook.sh" ]; then /bin/sh "/Users/alice/Library/Application Support/Orca/agent-hooks/claude-hook.sh"; fi'
|
||||
|
|
@ -176,6 +174,17 @@ describe('createManagedCommandMatcher', () => {
|
|||
expect(match(command)).toBe(true)
|
||||
})
|
||||
|
||||
it('matches PowerShell and POSIX variants across Copilot platform switches', () => {
|
||||
const matchPosix = createManagedCommandMatcher('copilot-hook.sh')
|
||||
const matchPowerShell = createManagedCommandMatcher('copilot-hook.ps1')
|
||||
|
||||
expect(matchPosix("& 'C:\\Users\\alice\\.orca\\agent-hooks\\copilot-hook.ps1'")).toBe(true)
|
||||
expect(
|
||||
matchPosix(wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\copilot-hook.ps1'))
|
||||
).toBe(true)
|
||||
expect(matchPowerShell("/bin/sh '/home/alice/.orca/agent-hooks/copilot-hook.sh'")).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the legacy per-userData script path AND the new shared ~/.orca path', () => {
|
||||
// Why: install() must sweep old per-userData commands when migrating to
|
||||
// the shared ~/.orca script path, or stale launchers keep failing.
|
||||
|
|
@ -285,13 +294,15 @@ describe('writeManagedScript', () => {
|
|||
describe('wrapPosixHookCommand', () => {
|
||||
it('produces a guarded command that no-ops when the script is missing', () => {
|
||||
const cmd = wrapPosixHookCommand('/does/not/exist.sh')
|
||||
expect(cmd).toBe("if [ -x '/does/not/exist.sh' ]; then /bin/sh '/does/not/exist.sh'; fi")
|
||||
expect(cmd).toBe(
|
||||
"if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then /bin/sh '/does/not/exist.sh'; else cat >/dev/null 2>&1 || :; fi"
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves spaces in the script path (Library/Application Support case)', () => {
|
||||
// Why: Electron's userData on macOS lives under "Application Support" with
|
||||
// a space. The guard must keep the path quoted so `[ -x ]` and `/bin/sh`
|
||||
// each see one argument.
|
||||
// a space. The guard must keep the path quoted so each file test and
|
||||
// `/bin/sh` see one argument.
|
||||
const cmd = wrapPosixHookCommand('/Users/a/Library/Application Support/Orca/agent-hooks/x.sh')
|
||||
expect(cmd).toContain("'/Users/a/Library/Application Support/Orca/agent-hooks/x.sh'")
|
||||
})
|
||||
|
|
@ -302,7 +313,7 @@ describe('wrapPosixHookCommand', () => {
|
|||
// /bin/sh as a single argument.
|
||||
const cmd = wrapPosixHookCommand("/path/with'quote/x.sh")
|
||||
expect(cmd).toBe(
|
||||
"if [ -x '/path/with'\\''quote/x.sh' ]; then /bin/sh '/path/with'\\''quote/x.sh'; fi"
|
||||
"if [ -f '/path/with'\\''quote/x.sh' ] && [ -r '/path/with'\\''quote/x.sh' ] && [ -x '/path/with'\\''quote/x.sh' ]; then /bin/sh '/path/with'\\''quote/x.sh'; else cat >/dev/null 2>&1 || :; fi"
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -311,7 +322,7 @@ describe('wrapPosixHookCommand', () => {
|
|||
ORCA_COPILOT_HOOK_EVENT: 'UserPromptSubmit'
|
||||
})
|
||||
expect(cmd).toBe(
|
||||
"if [ -x '/does/not/exist.sh' ]; then ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit' /bin/sh '/does/not/exist.sh'; fi"
|
||||
"if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit' /bin/sh '/does/not/exist.sh'; else cat >/dev/null 2>&1 || :; fi"
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -324,6 +335,35 @@ describe('wrapPosixHookCommand', () => {
|
|||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'drains stdin when a directory occupies the managed script path',
|
||||
() => {
|
||||
const scriptPath = join(tmpDir, 'directory-hook.sh')
|
||||
mkdirSync(scriptPath)
|
||||
const result = spawnSync('/bin/sh', ['-c', wrapPosixHookCommand(scriptPath)], {
|
||||
input: Buffer.alloc(1_000_000, 'x')
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
'drains stdin when the managed script is executable but unreadable',
|
||||
() => {
|
||||
const scriptPath = join(tmpDir, 'unreadable-hook.sh')
|
||||
writeFileSync(scriptPath, '#!/bin/sh\nexit 0\n', 'utf-8')
|
||||
chmodSync(scriptPath, 0o111)
|
||||
const result = spawnSync('/bin/sh', ['-c', wrapPosixHookCommand(scriptPath)], {
|
||||
input: Buffer.alloc(1_000_000, 'x')
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
// Why: commit 4d618795 explicitly switched from `&& ... || true` (which
|
||||
// swallowed non-zero exits) to `if ... then ... fi` (which preserves the
|
||||
// script's exit code). This test guards against a future regression that
|
||||
|
|
@ -350,13 +390,27 @@ function decodeWindowsHookCommand(command: string): string {
|
|||
return Buffer.from(encodedCommand!, 'base64').toString('utf16le')
|
||||
}
|
||||
|
||||
function expectedDecodedWindowsHookCommand(scriptPath: string): string {
|
||||
const quoted = `'${scriptPath.replaceAll("'", "''")}'`
|
||||
return `if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
}
|
||||
|
||||
describe('wrapWindowsHookCommand', () => {
|
||||
it('invokes the .cmd through an encoded PowerShell command', () => {
|
||||
const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd')
|
||||
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(command).not.toMatch(/^powershell\b/i)
|
||||
expect(decodeWindowsHookCommand(command)).toBe(
|
||||
"& 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
|
||||
expectedDecodedWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd')
|
||||
)
|
||||
})
|
||||
|
||||
it('scopes environment variables inside the encoded launcher', () => {
|
||||
const command = wrapWindowsHookCommand('C:\\hooks\\copilot-hook.ps1', {
|
||||
ORCA_COPILOT_HOOK_EVENT: 'UserPromptSubmit'
|
||||
})
|
||||
expect(decodeWindowsHookCommand(command)).toContain(
|
||||
"$env:ORCA_COPILOT_HOOK_EVENT = 'UserPromptSubmit'; if (Test-Path"
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -367,7 +421,9 @@ describe('wrapWindowsHookCommand', () => {
|
|||
const cmd = wrapWindowsHookCommand('C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd')
|
||||
expect(cmd).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(decodeWindowsHookCommand(cmd)).toBe(
|
||||
"& 'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
|
||||
expectedDecodedWindowsHookCommand(
|
||||
'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -376,7 +432,7 @@ describe('wrapWindowsHookCommand', () => {
|
|||
expect(cmd).not.toContain('%ORCA_TEST%')
|
||||
expect(cmd).not.toContain('^')
|
||||
expect(decodeWindowsHookCommand(cmd)).toBe(
|
||||
"& 'C:\\Users\\%ORCA_TEST%\\a^b\\codex-hook.cmd'; exit $LASTEXITCODE"
|
||||
expectedDecodedWindowsHookCommand('C:\\Users\\%ORCA_TEST%\\a^b\\codex-hook.cmd')
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -398,31 +454,52 @@ describe('wrapWindowsHookCommand', () => {
|
|||
})
|
||||
|
||||
describe('wrapWindowsCmdHookCommand', () => {
|
||||
it('returns a bare .cmd path when cmd.exe can invoke it safely', () => {
|
||||
it('keeps the safe-path launcher PowerShell-free and drains non-file paths', () => {
|
||||
const scriptPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'
|
||||
expect(wrapWindowsCmdHookCommand(scriptPath)).toBe(scriptPath)
|
||||
const command = wrapWindowsCmdHookCommand(scriptPath)
|
||||
expect(command).toContain(`if exist "${scriptPath}\\."`)
|
||||
expect(command).toContain(`if exist "${scriptPath}" (call "${scriptPath}")`)
|
||||
expect(command).toContain('"%SystemRoot%\\System32\\more.com" >nul 2>nul')
|
||||
expect(command).not.toMatch(/powershell/i)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'drains stdin when a directory occupies the managed script path',
|
||||
() => {
|
||||
const scriptPath = 'directory-hook.cmd'
|
||||
mkdirSync(join(tmpDir, scriptPath))
|
||||
const result = spawnSync('cmd.exe', ['/d', '/c', wrapWindowsCmdHookCommand(scriptPath)], {
|
||||
cwd: tmpDir,
|
||||
input: Buffer.alloc(1_000_000, 'x')
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('falls back to the encoded launcher when cmd.exe would split or expand the path', () => {
|
||||
const scriptPath = 'C:\\Users\\Jane Doe\\%ORCA_TEST%\\codex-hook.cmd'
|
||||
const command = wrapWindowsCmdHookCommand(scriptPath)
|
||||
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`)
|
||||
expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath))
|
||||
})
|
||||
})
|
||||
|
||||
describe('wrapWindowsGitBashHookCommand', () => {
|
||||
it('returns a forward-slash .cmd path when Git Bash can execute it safely', () => {
|
||||
it('guards the forward-slash fast path and drains when missing', () => {
|
||||
expect(
|
||||
wrapWindowsGitBashHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd')
|
||||
).toBe('C:/Users/alice/.orca/agent-hooks/claude-hook.cmd')
|
||||
).toBe(
|
||||
"if [ -f 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd' ]; then 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'; else cat >/dev/null 2>&1 || :; fi"
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the encoded launcher when bash would split the path', () => {
|
||||
const scriptPath = 'C:\\Users\\Jane Doe\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
const command = wrapWindowsGitBashHookCommand(scriptPath)
|
||||
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`)
|
||||
expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath))
|
||||
})
|
||||
|
||||
it('falls back to the encoded launcher when bash metacharacters are present', () => {
|
||||
|
|
@ -430,7 +507,7 @@ describe('wrapWindowsGitBashHookCommand', () => {
|
|||
const command = wrapWindowsGitBashHookCommand(scriptPath)
|
||||
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(command).not.toContain('& bob')
|
||||
expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`)
|
||||
expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import { dirname, join } from 'node:path'
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentHookSource } from '../../shared/agent-hook-relay'
|
||||
import { grantDirAcl, isPermissionError } from '../win32-utils'
|
||||
import {
|
||||
POSIX_HOOK_STDIN_DRAIN_COMMAND,
|
||||
WINDOWS_HOOK_STDIN_DRAIN_COMMAND
|
||||
} from './hook-stdin-contract'
|
||||
|
||||
export type HookCommandConfig = {
|
||||
type: 'command'
|
||||
|
|
@ -86,12 +90,13 @@ export function readHooksJson(configPath: string): HooksConfig | null {
|
|||
export function createManagedCommandMatcher(
|
||||
scriptFileName: string
|
||||
): (command: string | undefined) => boolean {
|
||||
const scriptStem = scriptFileName.replace(/\.(?:cmd|sh)$/, '')
|
||||
// Why: local Windows installs use .cmd, while SSH/POSIX installs and older
|
||||
// entries use .sh. A platform switch should still sweep stale Orca hooks.
|
||||
const scriptStem = scriptFileName.replace(/\.(?:cmd|ps1|sh)$/, '')
|
||||
// Why: local Windows installs use .cmd or Copilot's .ps1, while SSH/POSIX
|
||||
// installs use .sh. A platform switch must still sweep stale Orca hooks.
|
||||
const needles = [
|
||||
`agent-hooks/${scriptFileName}`,
|
||||
`agent-hooks/${scriptStem}.cmd`,
|
||||
`agent-hooks/${scriptStem}.ps1`,
|
||||
`agent-hooks/${scriptStem}.sh`
|
||||
]
|
||||
return (command) => {
|
||||
|
|
@ -123,24 +128,26 @@ export function getSharedManagedScriptPath(scriptFileName: string): string {
|
|||
return join(homedir(), '.orca', 'agent-hooks', scriptFileName)
|
||||
}
|
||||
|
||||
function quotePosixShellString(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
|
||||
// Why: a stale managed hook entry (left over after the user wiped userData,
|
||||
// switched dev↔prod installs, or had a partial install fail) used to fire
|
||||
// `/bin/sh "<missing path>"` on every tool call, which exits 127 and surfaces
|
||||
// as `PreToolUse hook (failed) error: hook exited with code 127` in the agent
|
||||
// transcript. Wrapping the launcher in `if [ -x ... ]; then ...; fi` makes a
|
||||
// missing/non-executable script a silent no-op so a broken install never
|
||||
// poisons the user's session. Failures inside the script itself are
|
||||
// unaffected — only the missing-script case short-circuits.
|
||||
// transcript. Guarding for a regular readable executable file makes a broken
|
||||
// install a silent no-op without hiding failures from a script that starts.
|
||||
export function wrapPosixHookCommand(scriptPath: string, env: Record<string, string> = {}): string {
|
||||
// Why: POSIX single-quote escape so $, `, ", and \ in scriptPath are taken
|
||||
// literally — avoids a shell-injection footgun if a future caller passes an
|
||||
// arbitrary path.
|
||||
const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'`
|
||||
const quoted = quotePosixShellString(scriptPath)
|
||||
const envPrefix = Object.entries(env)
|
||||
.map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`)
|
||||
.join(' ')
|
||||
const invocation = envPrefix ? `${envPrefix} /bin/sh ${quoted}` : `/bin/sh ${quoted}`
|
||||
return `if [ -x ${quoted} ]; then ${invocation}; fi`
|
||||
return `if [ -f ${quoted} ] && [ -r ${quoted} ] && [ -x ${quoted} ]; then ${invocation}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
}
|
||||
|
||||
function quotePowerShellString(value: string): string {
|
||||
|
|
@ -154,11 +161,17 @@ function getWindowsPowerShellExecutablePath(): string {
|
|||
return `${systemRoot.replaceAll('\\', '/')}/System32/WindowsPowerShell/v1.0/powershell.exe`
|
||||
}
|
||||
|
||||
export function wrapWindowsHookCommand(scriptPath: string): string {
|
||||
// Why: most Windows agents run hooks through Git Bash or another shell that
|
||||
// mangles a raw backslash path. Codex has its own cmd.exe-safe fast path; the
|
||||
// shared wrapper keeps the encoded launcher for every other agent.
|
||||
const command = `& ${quotePowerShellString(scriptPath)}; exit $LASTEXITCODE`
|
||||
export function wrapWindowsHookCommand(
|
||||
scriptPath: string,
|
||||
env: Record<string, string> = {}
|
||||
): string {
|
||||
// Why: the encoded launcher protects paths across Windows hook shells and
|
||||
// owns stdin when a stale config points at a missing managed script.
|
||||
const quoted = quotePowerShellString(scriptPath)
|
||||
const envPrefix = Object.entries(env)
|
||||
.map(([key, value]) => `$env:${key} = ${quotePowerShellString(value)}; `)
|
||||
.join('')
|
||||
const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
const encodedCommand = Buffer.from(command, 'utf16le').toString('base64')
|
||||
return `${getWindowsPowerShellExecutablePath()} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
|
||||
}
|
||||
|
|
@ -166,19 +179,23 @@ export function wrapWindowsHookCommand(scriptPath: string): string {
|
|||
export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/
|
||||
|
||||
export function wrapWindowsCmdHookCommand(scriptPath: string): string {
|
||||
// Why: skip the ~360ms PowerShell interpreter startup when the path is safe
|
||||
// for cmd.exe to invoke directly. The fallback keeps the encoded PowerShell
|
||||
// launcher for paths with spaces or metacharacters.
|
||||
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
|
||||
// Why: keep the safe-path fast path PowerShell-free while making a stale
|
||||
// config entry own stdin; the `path\.` sentinel also rejects a directory
|
||||
// that happens to occupy the managed .cmd path, including an empty one.
|
||||
return WINDOWS_CMD_SAFE_PATH.test(scriptPath)
|
||||
? `if exist "${scriptPath}\\." (${WINDOWS_HOOK_STDIN_DRAIN_COMMAND}) else if exist "${scriptPath}" (call "${scriptPath}") else (${WINDOWS_HOOK_STDIN_DRAIN_COMMAND})`
|
||||
: wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/
|
||||
|
||||
export function wrapWindowsGitBashHookCommand(scriptPath: string): string {
|
||||
const bashPath = scriptPath.replaceAll('\\', '/')
|
||||
// Why: Claude Code's Windows hook runner is Git Bash/MSYS. It can execute a
|
||||
// bare .cmd via forward slashes, but shell metacharacters must stay encoded.
|
||||
return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath) ? bashPath : wrapWindowsHookCommand(scriptPath)
|
||||
// Why: Claude's Git Bash runner can execute a forward-slash .cmd directly;
|
||||
// unsafe paths stay encoded and the fast path gains a missing-file drain.
|
||||
return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath)
|
||||
? `if [ -f ${quotePosixShellString(bashPath)} ]; then ${quotePosixShellString(bashPath)}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
: wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string {
|
||||
|
|
@ -300,7 +317,7 @@ export function writeManagedScript(scriptPath: string, content: string): void {
|
|||
try {
|
||||
writeScriptWithAclRetry(tmpPath, content)
|
||||
// Why: chmod before rename so the canonical path is never visible in a
|
||||
// non-executable state; wrapPosixHookCommand's `[ -x ]` guard would
|
||||
// unreadable/non-executable state; wrapPosixHookCommand's guards would
|
||||
// silently skip the hook in that window.
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(tmpPath, 0o755)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,366 @@
|
|||
// Why: stdin ownership is a cross-agent process contract; one executable
|
||||
// matrix catches an unread early exit without duplicating template assertions.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import type * as osModule from 'node:os'
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/orca-user-data'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof osModule>()
|
||||
return {
|
||||
...actual,
|
||||
homedir: homedirMock.mockImplementation(actual.homedir)
|
||||
}
|
||||
})
|
||||
|
||||
import { AntigravityHookService } from '../antigravity/hook-service'
|
||||
import { ClaudeHookService } from '../claude/hook-service'
|
||||
import { CodexHookService } from '../codex/hook-service'
|
||||
import { CommandCodeHookService } from '../command-code/hook-service'
|
||||
import { CopilotHookService } from '../copilot/hook-service'
|
||||
import { CursorHookService } from '../cursor/hook-service'
|
||||
import { DevinHookService } from '../devin/hook-service'
|
||||
import { DroidHookService } from '../droid/hook-service'
|
||||
import { GeminiHookService } from '../gemini/hook-service'
|
||||
import { GrokHookService } from '../grok/hook-service'
|
||||
import { KimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
import {
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsCmdHookCommand,
|
||||
wrapWindowsGitBashHookCommand,
|
||||
wrapWindowsHookCommand
|
||||
} from './installer-utils'
|
||||
import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture'
|
||||
|
||||
const REMOTE_HOME = '/home/dev'
|
||||
const LARGE_PAYLOAD = Buffer.alloc(1_000_000, 'x')
|
||||
const REMOTE_INSTALLERS = [
|
||||
{
|
||||
agent: 'antigravity',
|
||||
install: (sftp: SFTPWrapper) => new AntigravityHookService().installRemote(sftp, REMOTE_HOME)
|
||||
},
|
||||
{
|
||||
agent: 'claude',
|
||||
install: (sftp: SFTPWrapper) => new ClaudeHookService().installRemote(sftp, REMOTE_HOME)
|
||||
},
|
||||
{
|
||||
agent: 'openclaude',
|
||||
install: (sftp: SFTPWrapper) => openClaudeHookService.installRemote(sftp, REMOTE_HOME)
|
||||
},
|
||||
{
|
||||
agent: 'codex',
|
||||
install: (sftp: SFTPWrapper) => new CodexHookService().installRemote(sftp, REMOTE_HOME)
|
||||
},
|
||||
{
|
||||
agent: 'command-code',
|
||||
install: (sftp: SFTPWrapper) => new CommandCodeHookService().installRemote(sftp, REMOTE_HOME)
|
||||
},
|
||||
{
|
||||
agent: 'copilot',
|
||||
install: (sftp: SFTPWrapper) => new CopilotHookService().installRemote(sftp, REMOTE_HOME)
|
||||
},
|
||||
{
|
||||
agent: 'cursor',
|
||||
install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, REMOTE_HOME)
|
||||
},
|
||||
{
|
||||
agent: 'devin',
|
||||
install: (sftp: SFTPWrapper) => new DevinHookService().installRemote(sftp, REMOTE_HOME)
|
||||
},
|
||||
{
|
||||
agent: 'droid',
|
||||
install: (sftp: SFTPWrapper) => new DroidHookService().installRemote(sftp, REMOTE_HOME)
|
||||
},
|
||||
{
|
||||
agent: 'gemini',
|
||||
install: (sftp: SFTPWrapper) => new GeminiHookService().installRemote(sftp, REMOTE_HOME)
|
||||
},
|
||||
{
|
||||
agent: 'grok',
|
||||
install: (sftp: SFTPWrapper) => new GrokHookService().installRemote(sftp, REMOTE_HOME)
|
||||
},
|
||||
{
|
||||
agent: 'kimi',
|
||||
install: (sftp: SFTPWrapper) => new KimiHookService().installRemote(sftp, REMOTE_HOME)
|
||||
}
|
||||
] as const
|
||||
|
||||
const LOCAL_INSTALLERS = [
|
||||
{ agent: 'antigravity', install: () => new AntigravityHookService().install() },
|
||||
{ agent: 'claude', install: () => new ClaudeHookService().install() },
|
||||
{ agent: 'openclaude', install: () => openClaudeHookService.install() },
|
||||
{ agent: 'codex', install: () => new CodexHookService().install() },
|
||||
{ agent: 'command-code', install: () => new CommandCodeHookService().install() },
|
||||
{ agent: 'copilot', install: () => new CopilotHookService().install() },
|
||||
{ agent: 'cursor', install: () => new CursorHookService().install() },
|
||||
{ agent: 'devin', install: () => new DevinHookService().install() },
|
||||
{ agent: 'droid', install: () => new DroidHookService().install() },
|
||||
{ agent: 'gemini', install: () => new GeminiHookService().install() },
|
||||
{ agent: 'grok', install: () => new GrokHookService().install() },
|
||||
{ agent: 'kimi', install: () => new KimiHookService().install() }
|
||||
] as const
|
||||
|
||||
type HookRun = {
|
||||
exitCode: number | null
|
||||
stdinErrors: NodeJS.ErrnoException[]
|
||||
}
|
||||
|
||||
function runHookProcess(
|
||||
executable: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<HookRun> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, args, { env, stdio: ['pipe', 'ignore', 'ignore'] })
|
||||
const stdinErrors: NodeJS.ErrnoException[] = []
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error('hook did not finish after stdin closed'))
|
||||
}, 10_000)
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
child.stdin.on('error', (error: NodeJS.ErrnoException) => stdinErrors.push(error))
|
||||
child.on('close', (exitCode) => {
|
||||
clearTimeout(timeout)
|
||||
resolve({ exitCode, stdinErrors })
|
||||
})
|
||||
child.stdin.end(LARGE_PAYLOAD)
|
||||
})
|
||||
}
|
||||
|
||||
function hookEnvironment(extraEnv: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_'))
|
||||
)
|
||||
return {
|
||||
...env,
|
||||
HOME: REMOTE_HOME,
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '',
|
||||
...extraEnv
|
||||
}
|
||||
}
|
||||
|
||||
function runPosixHook(command: string, extraEnv: NodeJS.ProcessEnv = {}): Promise<HookRun> {
|
||||
return runHookProcess('/bin/sh', ['-c', command], hookEnvironment(extraEnv))
|
||||
}
|
||||
|
||||
async function generatePosixScripts(): Promise<Map<string, string>> {
|
||||
const scripts = new Map<string, string>()
|
||||
for (const entry of REMOTE_INSTALLERS) {
|
||||
const memory = createAgentHookMemorySftp()
|
||||
const status = await entry.install(memory.sftp)
|
||||
expect(status.state, `${entry.agent} install status`).toBe('installed')
|
||||
const generated = [...memory.fs.files.entries()].filter(
|
||||
([path]) => path.includes('/.orca/agent-hooks/') && path.endsWith('.sh')
|
||||
)
|
||||
expect(generated, `${entry.agent} generated scripts`).toHaveLength(1)
|
||||
scripts.set(entry.agent, generated[0][1])
|
||||
}
|
||||
return scripts
|
||||
}
|
||||
|
||||
function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
|
||||
const original = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
return run()
|
||||
} finally {
|
||||
if (original) {
|
||||
Object.defineProperty(process, 'platform', original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Windows managed hook stdin structure', () => {
|
||||
it('routes every batch guard to a shared drain epilogue', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-windows-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
const previousGrokHome = process.env.GROK_HOME
|
||||
const previousKimiHome = process.env.KIMI_CODE_HOME
|
||||
delete process.env.GROK_HOME
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
try {
|
||||
withPlatform('win32', () => {
|
||||
for (const entry of LOCAL_INSTALLERS) {
|
||||
expect(entry.install().state, `${entry.agent} install status`).toBe('installed')
|
||||
}
|
||||
})
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
const fileNames = readdirSync(hooksDir)
|
||||
const mainBatchScripts = fileNames.filter(
|
||||
(name) => name.endsWith('-hook.cmd') && !name.startsWith('antigravity-')
|
||||
)
|
||||
mainBatchScripts.push('antigravity-hook.cmd')
|
||||
expect(mainBatchScripts).toHaveLength(10)
|
||||
for (const fileName of mainBatchScripts) {
|
||||
const script = readFileSync(join(hooksDir, fileName), 'utf8')
|
||||
expect(script, `${fileName} port guard`).toContain(
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin'
|
||||
)
|
||||
expect(script, `${fileName} token guard`).toContain(
|
||||
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" goto :orca_agent_hook_drain_stdin'
|
||||
)
|
||||
expect(script, `${fileName} pane guard`).toContain(
|
||||
'if "%ORCA_PANE_KEY%"=="" goto :orca_agent_hook_drain_stdin'
|
||||
)
|
||||
expect(script, `${fileName} drain epilogue`).toContain(
|
||||
[
|
||||
':orca_agent_hook_drain_stdin',
|
||||
'"%SystemRoot%\\System32\\more.com" >nul 2>nul',
|
||||
'exit /b 0'
|
||||
].join('\r\n')
|
||||
)
|
||||
}
|
||||
|
||||
const copilot = readFileSync(join(hooksDir, 'copilot-hook.ps1'), 'utf8')
|
||||
expect(copilot.indexOf('[Console]::In.ReadToEnd()')).toBeLessThan(
|
||||
copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT')
|
||||
)
|
||||
const kimi = readFileSync(join(hooksDir, 'kimi-hook.sh'), 'utf8')
|
||||
expect(kimi.indexOf('payload=$(cat)')).toBeLessThan(kimi.indexOf('exit 0'))
|
||||
} finally {
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
if (previousGrokHome === undefined) {
|
||||
delete process.env.GROK_HOME
|
||||
} else {
|
||||
process.env.GROK_HOME = previousGrokHome
|
||||
}
|
||||
if (previousKimiHome === undefined) {
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
} else {
|
||||
process.env.KIMI_CODE_HOME = previousKimiHome
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'executes every local script and missing-script launcher without a broken writer',
|
||||
async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-windows-live-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
try {
|
||||
for (const entry of LOCAL_INSTALLERS) {
|
||||
expect(entry.install().state, `${entry.agent} install status`).toBe('installed')
|
||||
}
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
const mainScripts = readdirSync(hooksDir).filter(
|
||||
(name) =>
|
||||
name === 'antigravity-hook.cmd' ||
|
||||
name.endsWith('-hook.ps1') ||
|
||||
name.endsWith('-hook.sh') ||
|
||||
(name.endsWith('-hook.cmd') && !name.startsWith('antigravity-'))
|
||||
)
|
||||
expect(mainScripts).toHaveLength(12)
|
||||
for (const fileName of mainScripts) {
|
||||
const scriptPath = join(hooksDir, fileName)
|
||||
const executable = fileName.endsWith('.cmd')
|
||||
? 'cmd.exe'
|
||||
: fileName.endsWith('.ps1')
|
||||
? join(
|
||||
process.env.SystemRoot ?? 'C:\\Windows',
|
||||
'System32',
|
||||
'WindowsPowerShell',
|
||||
'v1.0',
|
||||
'powershell.exe'
|
||||
)
|
||||
: process.env.KIMI_SHELL_PATH || 'bash.exe'
|
||||
const args = fileName.endsWith('.cmd')
|
||||
? ['/d', '/c', scriptPath]
|
||||
: fileName.endsWith('.ps1')
|
||||
? ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath]
|
||||
: [scriptPath]
|
||||
const result = await runHookProcess(executable, args, hookEnvironment())
|
||||
expect(result.exitCode, `${fileName} exit code`).toBe(0)
|
||||
expect(result.stdinErrors, `${fileName} stdin errors`).toHaveLength(0)
|
||||
}
|
||||
|
||||
const missingScript = 'C:\\missing\\orca-hook.cmd'
|
||||
const launcherCases = [
|
||||
{
|
||||
name: 'encoded PowerShell',
|
||||
executable: 'cmd.exe',
|
||||
args: ['/d', '/c', wrapWindowsHookCommand(missingScript)]
|
||||
},
|
||||
{
|
||||
name: 'cmd fast path',
|
||||
executable: 'cmd.exe',
|
||||
args: ['/d', '/c', wrapWindowsCmdHookCommand(missingScript)]
|
||||
},
|
||||
{
|
||||
name: 'Git Bash fast path',
|
||||
executable: process.env.KIMI_SHELL_PATH || 'bash.exe',
|
||||
args: ['-lc', wrapWindowsGitBashHookCommand(missingScript)]
|
||||
}
|
||||
]
|
||||
for (const launcher of launcherCases) {
|
||||
const result = await runHookProcess(launcher.executable, launcher.args, hookEnvironment())
|
||||
expect(result.exitCode, `${launcher.name} exit code`).toBe(0)
|
||||
expect(result.stdinErrors, `${launcher.name} stdin errors`).toHaveLength(0)
|
||||
}
|
||||
} finally {
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () => {
|
||||
it('captures stdin before every possible whole-script success exit', async () => {
|
||||
const scripts = await generatePosixScripts()
|
||||
for (const [agent, script] of scripts) {
|
||||
const captureIndex = script.indexOf('payload=$(cat)')
|
||||
const firstExitIndex = script.indexOf('exit 0')
|
||||
expect(captureIndex, `${agent} payload capture`).toBeGreaterThanOrEqual(0)
|
||||
expect(firstExitIndex, `${agent} first success exit`).toBeGreaterThan(captureIndex)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a large payload without Orca environment or a broken writer', async () => {
|
||||
const scripts = await generatePosixScripts()
|
||||
for (const [agent, script] of scripts) {
|
||||
const extraEnv =
|
||||
agent === 'command-code'
|
||||
? {
|
||||
ORCA_AGENT_HOOK_PORT: '1',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'test-token',
|
||||
ORCA_PANE_KEY: 'test-pane'
|
||||
}
|
||||
: {}
|
||||
const result = await runPosixHook(script, extraEnv)
|
||||
expect(result.exitCode, `${agent} exit code`).toBe(0)
|
||||
expect(result.stdinErrors, `${agent} stdin errors`).toHaveLength(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('drains before Claude skips hooks imported by Devin', async () => {
|
||||
const script = (await generatePosixScripts()).get('claude')
|
||||
expect(script).toBeDefined()
|
||||
const result = await runPosixHook(script!, { DEVIN_PROJECT_DIR: '/tmp/devin-project' })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdinErrors).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drains a large payload when the configured script is missing', async () => {
|
||||
const result = await runPosixHook(wrapPosixHookCommand('/missing/orca-hook.sh'))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdinErrors).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -41,93 +41,7 @@ import { DevinHookService } from '../devin/hook-service'
|
|||
import { DroidHookService } from '../droid/hook-service'
|
||||
import { KimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
|
||||
type FakeFs = {
|
||||
files: Map<string, string>
|
||||
dirs: Set<string>
|
||||
modes: Map<string, number>
|
||||
}
|
||||
|
||||
function createFakeSftp(initialFiles: Record<string, string> = {}): {
|
||||
sftp: SFTPWrapper
|
||||
fs: FakeFs
|
||||
} {
|
||||
const fs: FakeFs = {
|
||||
files: new Map(Object.entries(initialFiles)),
|
||||
dirs: new Set(['/']),
|
||||
modes: new Map()
|
||||
}
|
||||
const noEntryError = (path: string): { code: number; message: string } => ({
|
||||
code: 2,
|
||||
message: `ENOENT ${path}`
|
||||
})
|
||||
|
||||
const sftp = {
|
||||
readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
|
||||
const v = fs.files.get(path)
|
||||
if (v === undefined) {
|
||||
cb(noEntryError(path))
|
||||
return
|
||||
}
|
||||
cb(null, v)
|
||||
},
|
||||
writeFile: (
|
||||
path: string,
|
||||
content: string,
|
||||
options: string | { mode?: number },
|
||||
cb: (err: unknown) => void
|
||||
): void => {
|
||||
fs.files.set(path, content)
|
||||
if (typeof options !== 'string' && options.mode !== undefined) {
|
||||
fs.modes.set(path, options.mode)
|
||||
}
|
||||
cb(null)
|
||||
},
|
||||
rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
|
||||
const v = fs.files.get(src)
|
||||
if (v === undefined) {
|
||||
cb(noEntryError(src))
|
||||
return
|
||||
}
|
||||
fs.files.set(dst, v)
|
||||
fs.files.delete(src)
|
||||
const mode = fs.modes.get(src)
|
||||
if (mode !== undefined) {
|
||||
fs.modes.set(dst, mode)
|
||||
fs.modes.delete(src)
|
||||
}
|
||||
cb(null)
|
||||
},
|
||||
unlink: (path: string, cb: (err: unknown) => void): void => {
|
||||
fs.files.delete(path)
|
||||
fs.modes.delete(path)
|
||||
cb(null)
|
||||
},
|
||||
chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
|
||||
fs.modes.set(path, mode)
|
||||
cb(null)
|
||||
},
|
||||
stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
|
||||
if (!fs.files.has(path)) {
|
||||
cb(noEntryError(path))
|
||||
return
|
||||
}
|
||||
cb(null, { mode: fs.modes.get(path) ?? 0o100644 })
|
||||
},
|
||||
readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
|
||||
if (fs.dirs.has(path)) {
|
||||
cb(null, [])
|
||||
return
|
||||
}
|
||||
cb(noEntryError(path))
|
||||
},
|
||||
mkdir: (path: string, cb: (err: unknown) => void): void => {
|
||||
fs.dirs.add(path)
|
||||
cb(null)
|
||||
}
|
||||
} as unknown as SFTPWrapper
|
||||
return { sftp, fs }
|
||||
}
|
||||
import { createAgentHookMemorySftp as createFakeSftp } from './agent-hook-memory-sftp.test-fixture'
|
||||
|
||||
const REMOTE_HOME = '/home/dev'
|
||||
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@ describe('remote hook service installers', () => {
|
|||
]) {
|
||||
const command = hooks.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/codex-hook.sh')
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
expect(command).toMatch(/^if \[ -f /)
|
||||
}
|
||||
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
|
||||
expect(fs.modes.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toBe(0o755)
|
||||
|
|
@ -338,7 +338,7 @@ describe('remote hook service installers', () => {
|
|||
for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool']) {
|
||||
const command = geminiConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/gemini-hook.sh')
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
expect(command).toMatch(/^if \[ -f /)
|
||||
}
|
||||
expect(geminiConfig.hooks.PreToolUse).toBeUndefined()
|
||||
|
||||
|
|
@ -398,7 +398,7 @@ describe('remote hook service installers', () => {
|
|||
const definition = commandCodeConfig.hooks[eventName]?.[0]
|
||||
const command = definition?.hooks?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/command-code-hook.sh')
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
expect(command).toMatch(/^if \[ -f /)
|
||||
}
|
||||
expect(commandCodeConfig.hooks.PreToolUse?.[0]?.matcher).toBe('.*')
|
||||
expect(commandCodeConfig.hooks.PostToolUse?.[0]?.matcher).toBe('.*')
|
||||
|
|
@ -421,7 +421,7 @@ describe('remote hook service installers', () => {
|
|||
const definition = grokConfig.hooks[eventName]?.[0]
|
||||
const command = definition?.hooks?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/grok-hook.sh')
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
expect(command).toMatch(/^if \[ -f /)
|
||||
}
|
||||
// Why: Grok tool matchers are real regexes; bare `*` is invalid match-all.
|
||||
expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('.*')
|
||||
|
|
@ -443,14 +443,14 @@ describe('remote hook service installers', () => {
|
|||
const definition = devinConfig.hooks[eventName]?.[0]
|
||||
const command = definition?.hooks?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/devin-hook.sh')
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
expect(command).toMatch(/^if \[ -f /)
|
||||
}
|
||||
for (const eventName of ['PreToolUse', 'PostToolUse', 'PermissionRequest']) {
|
||||
const definition = devinConfig.hooks[eventName]?.[0]
|
||||
const command = definition?.hooks?.[0]?.command
|
||||
expect(definition?.matcher).toBeUndefined()
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/devin-hook.sh')
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
expect(command).toMatch(/^if \[ -f /)
|
||||
}
|
||||
expect(devin.fs.files.get('/home/dev/.orca/agent-hooks/devin-hook.sh')).toContain('/hook/devin')
|
||||
})
|
||||
|
|
@ -506,9 +506,9 @@ describe('remote hook service installers', () => {
|
|||
]) {
|
||||
expect(config).toContain(`event = "${eventName}"`)
|
||||
}
|
||||
// The command points at the POSIX managed script via the `[ -x ]` guard.
|
||||
// The command points at the POSIX managed script via the regular-file guard.
|
||||
expect(config).toContain('/home/dev/.orca/agent-hooks/kimi-hook.sh')
|
||||
expect(config).toMatch(/command = "if \[ -x /)
|
||||
expect(config).toMatch(/command = "if \[ -f /)
|
||||
expect(fs.files.get('/home/dev/.orca/agent-hooks/kimi-hook.sh')).toContain('/hook/kimi')
|
||||
})
|
||||
|
||||
|
|
@ -749,7 +749,7 @@ describe('remote hook service installers', () => {
|
|||
const definition = config.hooks[eventName]?.[0]
|
||||
const command = definition?.hooks?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/droid-hook.sh')
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
expect(command).toMatch(/^if \[ -f /)
|
||||
}
|
||||
// Tool/permission events carry a `*` matcher; lifecycle events do not.
|
||||
expect(config.hooks.PreToolUse?.[0]?.matcher).toBe('*')
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ vi.mock('os', async () => {
|
|||
})
|
||||
|
||||
import { AntigravityHookService } from './hook-service'
|
||||
import { createManagedCommandMatcher } from '../agent-hooks/installer-utils'
|
||||
|
||||
const ANTIGRAVITY_SCRIPT_FILE_NAME =
|
||||
process.platform === 'win32' ? 'antigravity-hook.cmd' : 'antigravity-hook.sh'
|
||||
|
|
@ -165,10 +166,9 @@ describe('AntigravityHookService', () => {
|
|||
const definition = config['orca-status'][eventName][0]
|
||||
const command =
|
||||
eventName === 'PostToolUse' ? definition.hooks?.[0]?.command : definition.command
|
||||
expect(command).toContain(wrapperFileName)
|
||||
expect(createManagedCommandMatcher(wrapperFileName)(command)).toBe(true)
|
||||
expect(command).not.toContain('cmd /d /s /c')
|
||||
expect(command).not.toContain('ORCA_ANTIGRAVITY_EVENT')
|
||||
expect(command).not.toContain('"')
|
||||
|
||||
const wrapper = readFileSync(join(homeDir, '.orca', 'agent-hooks', wrapperFileName), 'utf8')
|
||||
expect(wrapper).toContain(`set "ORCA_ANTIGRAVITY_EVENT=${eventName}"`)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsCmdHookCommand,
|
||||
writeHooksJson,
|
||||
writeManagedScript,
|
||||
type HookDefinition,
|
||||
|
|
@ -24,6 +25,12 @@ import {
|
|||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue,
|
||||
WINDOWS_HOOK_STDIN_DRAIN_COMMAND
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
const ANTIGRAVITY_HOOK_BUNDLE_NAME = 'orca-status'
|
||||
|
||||
|
|
@ -76,7 +83,7 @@ function getWindowsWrapperScriptPath(event: AntigravityEvent): string {
|
|||
|
||||
function getManagedCommand(scriptPath: string, event: AntigravityEvent): string {
|
||||
if (process.platform === 'win32') {
|
||||
return getWindowsWrapperScriptPath(event)
|
||||
return wrapWindowsCmdHookCommand(getWindowsWrapperScriptPath(event))
|
||||
}
|
||||
return wrapPosixHookCommand(scriptPath, { ORCA_ANTIGRAVITY_EVENT: event.eventName })
|
||||
}
|
||||
|
|
@ -92,11 +99,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
' echo {}',
|
||||
')',
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
|
||||
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
|
||||
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
buildWindowsAntigravityHookPostCommand(),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
|
@ -114,18 +120,15 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
' printf "{}\\n"',
|
||||
' ;;',
|
||||
'esac',
|
||||
// Why: some Antigravity events arrive without stdin but still need a
|
||||
// status post, so the shared capture maps empty input to an object.
|
||||
...buildPosixHookPayloadCapture('empty-object'),
|
||||
'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
|
||||
|
|
@ -162,6 +165,9 @@ function getWindowsWrapperScript(eventName: string): string {
|
|||
') else (',
|
||||
' echo {}',
|
||||
')',
|
||||
// Why: when the shared core script is missing, this wrapper becomes the
|
||||
// stdin owner and must finish the agent's payload write before returning.
|
||||
WINDOWS_HOOK_STDIN_DRAIN_COMMAND,
|
||||
'exit /b 0',
|
||||
''
|
||||
].join('\r\n')
|
||||
|
|
|
|||
|
|
@ -247,8 +247,8 @@ describe('ClaudeHookService.installRemote', () => {
|
|||
expect(settings).toBeTruthy()
|
||||
const parsed = JSON.parse(settings!)
|
||||
// Why: every load-bearing event must be present and point at the
|
||||
// remote-shaped script path with the `if [ -x ... ]; then ... fi`
|
||||
// wrapper applied. Drift in any of these is a real bug — Claude
|
||||
// remote-shaped script path with the guarded launcher applied. Drift in
|
||||
// any of these is a real bug — Claude
|
||||
// Code rejects unknown shapes silently and the agent-hooks pipeline
|
||||
// goes dark.
|
||||
for (const event of [
|
||||
|
|
@ -266,7 +266,7 @@ describe('ClaudeHookService.installRemote', () => {
|
|||
expect(parsed.hooks[event]).toBeTruthy()
|
||||
const cmd = parsed.hooks[event][0].hooks[0].command as string
|
||||
expect(cmd).toContain('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
expect(cmd).toMatch(/^if \[ -x /)
|
||||
expect(cmd).toMatch(/^if \[ -f /)
|
||||
}
|
||||
// Managed script body
|
||||
const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
|
|
@ -355,7 +355,7 @@ describe('OpenClaudeHookService-compatible install', () => {
|
|||
const command = parsed.hooks[event][0].hooks[0].command as string
|
||||
expect(isOpenClaudeManagedCommand(command)).toBe(true)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
expect(command).toMatch(/^if \[ -f /)
|
||||
}
|
||||
}
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ import {
|
|||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue,
|
||||
WINDOWS_HOOK_STDIN_DRAIN_LABEL
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
import {
|
||||
applyManagedHooks,
|
||||
CLAUDE_EVENTS,
|
||||
|
|
@ -50,7 +56,7 @@ function getManagedScript(
|
|||
? [
|
||||
// Why: Devin imports .claude hooks by default. Skip Orca's managed
|
||||
// Claude hook there so status posts stay attributed to Devin.
|
||||
'if not "%DEVIN_PROJECT_DIR%"=="" exit /b 0'
|
||||
`if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`
|
||||
]
|
||||
: []),
|
||||
// Why: the endpoint file holds the *live* port/token for this Orca
|
||||
|
|
@ -60,9 +66,7 @@ function getManagedScript(
|
|||
// 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',
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
|
||||
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
|
||||
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
// Why: post via curl.exe, not a second PowerShell. Claude's launcher is
|
||||
// already an encoded PowerShell command (Git Bash needs it to survive
|
||||
// spaces); a PowerShell post on top of that meant two interpreter
|
||||
|
|
@ -70,12 +74,14 @@ function getManagedScript(
|
|||
// curl works the same here as for the POSIX/Codex hooks.
|
||||
buildWindowsAgentHookCurlPostCommand('claude'),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
...(options.skipWhenDevinImportsClaude
|
||||
? [
|
||||
// Why: Devin imports .claude hooks by default. Skip Orca's managed
|
||||
|
|
@ -105,10 +111,6 @@ function getManagedScript(
|
|||
'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.
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ function getManagedTrustEntry(
|
|||
}
|
||||
}
|
||||
|
||||
function expectedManagedCommand(scriptPath: string): string {
|
||||
return `if [ -f '${scriptPath}' ] && [ -r '${scriptPath}' ]; then /bin/sh '${scriptPath}'; else cat >/dev/null 2>&1 || :; fi`
|
||||
}
|
||||
|
||||
describe('Codex WSL runtime hook install', () => {
|
||||
it('plans WSL hook files with Linux command and trust paths', () => {
|
||||
const runtimeHome =
|
||||
|
|
@ -133,7 +137,7 @@ describe('Codex WSL runtime hook install', () => {
|
|||
trustConfigPath: '/old/home/hooks.json'
|
||||
}
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(oldPlan).state).toBe('installed')
|
||||
const oldCommand = `if [ -r '${oldPlan.commandScriptPath}' ]; then /bin/sh '${oldPlan.commandScriptPath}'; fi`
|
||||
const oldCommand = expectedManagedCommand(oldPlan.commandScriptPath)
|
||||
const oldKey = computeTrustKey(getManagedTrustEntry(oldPlan, oldCommand))
|
||||
|
||||
const newPlan = {
|
||||
|
|
@ -142,7 +146,7 @@ describe('Codex WSL runtime hook install', () => {
|
|||
trustConfigPath: '/new/home/hooks.json'
|
||||
}
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(newPlan).state).toBe('installed')
|
||||
const newCommand = `if [ -r '${newPlan.commandScriptPath}' ]; then /bin/sh '${newPlan.commandScriptPath}'; fi`
|
||||
const newCommand = expectedManagedCommand(newPlan.commandScriptPath)
|
||||
const newKey = computeTrustKey(getManagedTrustEntry(newPlan, newCommand))
|
||||
const trustEntries = readHookTrustEntries(plan.tomlPath)
|
||||
|
||||
|
|
@ -150,6 +154,30 @@ describe('Codex WSL runtime hook install', () => {
|
|||
expect(trustEntries.has(newKey)).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'drains stdin when the WSL runtime script is missing',
|
||||
() => {
|
||||
const basePlan = createTestPlan()
|
||||
const plan = {
|
||||
...basePlan,
|
||||
commandScriptPath: join(dirname(basePlan.configPath), 'missing-codex-hook.sh')
|
||||
}
|
||||
writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8')
|
||||
writeFileSync(plan.tomlPath, '', 'utf-8')
|
||||
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed')
|
||||
const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig
|
||||
const command = installed.hooks.UserPromptSubmit[0]?.hooks?.[0]?.command
|
||||
expect(command).toBe(expectedManagedCommand(plan.commandScriptPath))
|
||||
|
||||
const result = spawnSync('/bin/sh', ['-c', command!], {
|
||||
input: Buffer.alloc(1_000_000, 'x')
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('sweeps all managed WSL trust for disable or confirmed absence', () => {
|
||||
// Why: disable and confirmed absence intentionally pass []. Transient
|
||||
// unavailability must NOT use this path — last known-good trust remains.
|
||||
|
|
@ -351,9 +379,7 @@ describe('Codex WSL runtime hook install', () => {
|
|||
const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig
|
||||
expect(Object.keys(installed.hooks).sort()).toEqual([...managedEvents].sort())
|
||||
const managedCommand = installed.hooks.UserPromptSubmit[0]?.hooks?.[0]?.command
|
||||
expect(managedCommand).toBe(
|
||||
`if [ -r '${plan.commandScriptPath}' ]; then /bin/sh '${plan.commandScriptPath}'; fi`
|
||||
)
|
||||
expect(managedCommand).toBe(expectedManagedCommand(plan.commandScriptPath))
|
||||
expect(installed.hooks.UserPromptSubmit[1]?.hooks?.[0]?.command).toBe(userCommand)
|
||||
expect(readFileSync(plan.scriptPath, 'utf-8')).toContain('command -v curl.exe')
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ import {
|
|||
writeManagedScriptRemote,
|
||||
writeTextFileRemoteAtomic
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue,
|
||||
POSIX_HOOK_STDIN_DRAIN_COMMAND
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
import {
|
||||
computeTrustKey,
|
||||
computeTrustedHash,
|
||||
|
|
@ -136,8 +142,8 @@ export type { CodexWslRuntimeHookInstallPlan }
|
|||
function wrapReadablePosixHookCommand(scriptPath: string): string {
|
||||
const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'`
|
||||
// Why: WSL runtime hooks are written from Windows through UNC, where the
|
||||
// executable bit is not reliable. /bin/sh only needs the script to be readable.
|
||||
return `if [ -r ${quoted} ]; then /bin/sh ${quoted}; fi`
|
||||
// executable bit is not reliable; a missing script must still own stdin.
|
||||
return `if [ -f ${quoted} ] && [ -r ${quoted} ]; then /bin/sh ${quoted}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
}
|
||||
|
||||
function getSystemConfigPath(): string {
|
||||
|
|
@ -781,17 +787,17 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
// 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',
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
|
||||
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
|
||||
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
buildWindowsAgentHookCurlPostCommand('codex'),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
// 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.
|
||||
|
|
@ -823,10 +829,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
'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',
|
||||
'post_codex_hook() {',
|
||||
' curl_bin="$1"',
|
||||
' connect_timeout="${2:-0.5}"',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
export type CommandCodeManagedScriptTarget = 'local' | 'posix'
|
||||
|
||||
|
|
@ -11,9 +16,7 @@ export function buildCommandCodeManagedScript(
|
|||
'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',
|
||||
'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',
|
||||
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
buildWindowsAgentHookPostCommand('command-code'),
|
||||
'exit /b 0',
|
||||
':sourceEndpointByPort',
|
||||
|
|
@ -25,12 +28,14 @@ export function buildCommandCodeManagedScript(
|
|||
'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',
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
'__orca_read_ancestor_var() {',
|
||||
' __orca_name="$1"',
|
||||
' __orca_pid="${PPID:-}"',
|
||||
|
|
@ -116,10 +121,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ describe('CommandCodeHookService', () => {
|
|||
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/^if \[ -x /)
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/^if \[ -f /)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -88,12 +88,16 @@ describe('CopilotHookService', () => {
|
|||
expect(firstPromptHook.type).toBe('command')
|
||||
expect(firstPromptHook.timeoutSec).toBe(5)
|
||||
if (process.platform === 'win32') {
|
||||
expect(firstPromptHook.powershell).toContain('agent-hooks')
|
||||
expect(firstPromptHook.powershell).toContain('copilot-hook.ps1')
|
||||
expect(firstPromptHook.powershell).toContain('ORCA_COPILOT_HOOK_EVENT')
|
||||
expect(firstPromptHook.powershell).toContain('UserPromptSubmit')
|
||||
const powershell = firstPromptHook.powershell as string
|
||||
expect(powershell).toContain('-EncodedCommand')
|
||||
const encoded = powershell.match(/ -EncodedCommand (\S+)$/)?.[1]
|
||||
const decoded = Buffer.from(encoded!, 'base64').toString('utf16le')
|
||||
expect(decoded).toContain('agent-hooks')
|
||||
expect(decoded).toContain('copilot-hook.ps1')
|
||||
expect(decoded).toContain("$env:ORCA_COPILOT_HOOK_EVENT = 'UserPromptSubmit'")
|
||||
} else {
|
||||
expect(firstPromptHook.bash).toContain('if [ -x ')
|
||||
expect(firstPromptHook.bash).toContain('if [ -f ')
|
||||
expect(firstPromptHook.bash).toContain('] && [ -x ')
|
||||
expect(firstPromptHook.bash).toContain('.orca/agent-hooks/copilot-hook.sh')
|
||||
expect(firstPromptHook.bash).toContain("ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit'")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeHooksJson,
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
|
|
@ -21,6 +22,7 @@ import {
|
|||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
// Why: Copilot's user-level hook files can use VS Code-compatible PascalCase
|
||||
// names, which match the event vocabulary already normalized by Orca's hook
|
||||
|
|
@ -61,14 +63,11 @@ function getManagedScriptPath(): string {
|
|||
return getSharedManagedScriptPath(getManagedScriptFileName())
|
||||
}
|
||||
|
||||
function quotePowerShellPath(path: string): string {
|
||||
return `'${path.replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
function getManagedCommand(scriptPath: string, eventName: string): string {
|
||||
return process.platform === 'win32'
|
||||
? `$env:ORCA_COPILOT_HOOK_EVENT = '${eventName}'; powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${quotePowerShellPath(scriptPath)}`
|
||||
: wrapPosixHookCommand(scriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName })
|
||||
if (process.platform !== 'win32') {
|
||||
return wrapPosixHookCommand(scriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName })
|
||||
}
|
||||
return wrapWindowsHookCommand(scriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName })
|
||||
}
|
||||
|
||||
function getManagedHookDefinition(command: string): HookDefinition {
|
||||
|
|
@ -118,6 +117,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
"Write-Output '{}'",
|
||||
'$inputData = [Console]::In.ReadToEnd()',
|
||||
// Why: endpoint.cmd is cmd syntax, not PowerShell. Parse its `set KEY=...`
|
||||
// lines so surviving PTYs can refresh to the current Orca server.
|
||||
'if ($env:ORCA_AGENT_HOOK_ENDPOINT -and (Test-Path -LiteralPath $env:ORCA_AGENT_HOOK_ENDPOINT)) {',
|
||||
|
|
@ -130,7 +130,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
' } catch {}',
|
||||
'}',
|
||||
'if (-not $env:ORCA_AGENT_HOOK_PORT -or -not $env:ORCA_AGENT_HOOK_TOKEN -or -not $env:ORCA_PANE_KEY) { exit 0 }',
|
||||
'$inputData = [Console]::In.ReadToEnd()',
|
||||
'if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }',
|
||||
'try {',
|
||||
' $payload = $inputData | ConvertFrom-Json',
|
||||
|
|
@ -154,6 +153,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
return [
|
||||
'#!/bin/sh',
|
||||
"printf '{}\\n'",
|
||||
...buildPosixHookPayloadCapture(),
|
||||
// Why: Copilot consumes stdout for some hooks, so stdout 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',
|
||||
|
|
@ -162,10 +162,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
'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.
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ import {
|
|||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
// Why: cursor-agent exposes a declarative hooks.json surface at
|
||||
// ~/.cursor/hooks.json (https://cursor.com/docs/hooks) with camelCase event
|
||||
|
|
@ -76,17 +81,17 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
// 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',
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
|
||||
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
|
||||
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
buildWindowsAgentHookPostCommand('cursor'),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
// 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.
|
||||
|
|
@ -96,10 +101,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
'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.
|
||||
|
|
|
|||
|
|
@ -126,9 +126,12 @@ describe('DevinHookService', () => {
|
|||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
try {
|
||||
const scriptPath = 'C:\\Users\\Ada Lovelace\\.orca\\agent-hooks\\devin-hook.cmd'
|
||||
expect(getDevinManagedCommand(scriptPath)).toBe(
|
||||
'cmd /d /s /c ""C:\\Users\\Ada Lovelace\\.orca\\agent-hooks\\devin-hook.cmd""'
|
||||
)
|
||||
const command = getDevinManagedCommand(scriptPath)
|
||||
const encoded = command.match(/ -EncodedCommand (\S+)$/)?.[1]
|
||||
expect(encoded).toBeDefined()
|
||||
const decoded = Buffer.from(encoded!, 'base64').toString('utf16le')
|
||||
expect(decoded).toContain(`Test-Path -LiteralPath '${scriptPath}' -PathType Leaf`)
|
||||
expect(decoded).toContain('[Console]::In.ReadToEnd() | Out-Null')
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: previous })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ import {
|
|||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
import {
|
||||
applyDevinManagedHooks,
|
||||
DEVIN_EVENTS,
|
||||
|
|
@ -41,17 +46,17 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
// 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',
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
|
||||
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
|
||||
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
buildWindowsAgentHookPostCommand('devin'),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
// 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
|
||||
|
|
@ -72,10 +77,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
'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.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getSharedManagedScriptPath,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsCmdHookCommand,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
|
|
@ -51,9 +52,9 @@ export function getDevinRemoteConfigPath(remoteHome: string): string {
|
|||
|
||||
export function getDevinManagedCommand(scriptPath: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
// Why: Devin runs hooks through the platform shell on Windows; invoking the
|
||||
// .cmd via cmd.exe preserves spaces in the shared ~/.orca script path.
|
||||
return `cmd /d /s /c ""${scriptPath.replaceAll('"', '""')}""`
|
||||
// Why: keep safe paths on cmd.exe's fast path; the fallback protects spaced
|
||||
// paths, and both forms drain stdin for a stale missing-script entry.
|
||||
return wrapWindowsCmdHookCommand(scriptPath)
|
||||
}
|
||||
return wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ import {
|
|||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
// Why: SessionStart is installed (not just listened for) so that resuming a
|
||||
// droid session via `droid --resume` resets the per-pane prompt/tool caches
|
||||
|
|
@ -77,27 +82,23 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
'@echo off',
|
||||
'setlocal',
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
|
||||
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
|
||||
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
buildWindowsAgentHookPostCommand('droid'),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
'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
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ import {
|
|||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
// Why: Gemini CLI fires `BeforeAgent` when a turn starts and `AfterAgent` when
|
||||
// it completes. `AfterTool` marks the resumption of model work after a tool
|
||||
|
|
@ -65,11 +70,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
// 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',
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
|
||||
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
|
||||
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
buildWindowsAgentHookPostCommand('gemini'),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
|
@ -80,6 +84,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
// 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"',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
// 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.
|
||||
|
|
@ -89,10 +94,6 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
'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.
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ import {
|
|||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
// Why: Grok's tool-event matcher is a real regex (see Grok hooks docs). Bare
|
||||
// `*` is not a valid "match all" pattern and can fail to load/match, so tool
|
||||
|
|
@ -115,9 +120,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
'@echo off',
|
||||
'setlocal',
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
|
||||
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
|
||||
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
'set "ORCA_GROK_HOME=%GROK_HOME%"',
|
||||
`if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`,
|
||||
// Why: a trailing backslash escapes curl's closing argv quote on Windows,
|
||||
|
|
@ -125,22 +128,20 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
|||
'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."',
|
||||
WINDOWS_GROK_HOOK_POST_COMMAND,
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
'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',
|
||||
'grok_home=',
|
||||
`if [ -n "\${GROK_HOME:-}" ] && [ "\${#GROK_HOME}" -le ${GROK_HOME_ENVELOPE_MAX_LENGTH} ]; then`,
|
||||
' grok_home=$GROK_HOME',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
writeManagedScriptRemote,
|
||||
writeTextFileRemoteAtomic
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract'
|
||||
import {
|
||||
applyManagedKimiHooks,
|
||||
KIMI_HOOK_EVENTS,
|
||||
|
|
@ -59,6 +60,7 @@ function getManagedCommand(scriptPath: string): string {
|
|||
function getManagedScript(): string {
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
// 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.
|
||||
|
|
@ -68,10 +70,6 @@ function getManagedScript(): string {
|
|||
'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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue