205 lines
8.7 KiB
Markdown
205 lines
8.7 KiB
Markdown
# 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 (and Antigravity's/Devin's) cmd fast path is the bare, directly
|
|
spawnable `.cmd` path. These agents launch the hook command as a program
|
|
(argv[0]), not through cmd.exe, so the launcher cannot lead with a cmd builtin
|
|
such as `if` — that argv[0] is unspawnable and fails every hook. A missing
|
|
script therefore surfaces a normal launch failure on this fast path; only
|
|
launchers that already require a real interpreter (encoded PowerShell, Git
|
|
Bash) drain a missing script. Paths cmd.exe cannot invoke verbatim fall back to
|
|
the encoded PowerShell launcher, which owns stdin for the missing case.
|
|
- 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.
|