orca/docs/reference/agent-hook-stdin-lifecycle.md

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.