Agent status over WSL: guest-resident hook relay + WSL-side hook installers (STA-1515) (#7903)

* docs: full design + context for agent status over WSL (STA-1515)

Why hooks don't work on Windows+WSL (loopback transport gap + WSL-side
installation gap), per-client transport map, the OMP-only fixes that
shipped (7642/7641) and why they don't generalize, the recommended
guest-resident relay over wsl.exe stdio mirroring the SSH relay plus
WSL-side hook installers, alternatives considered, validation facts and
gotchas from the 2026-07-08 Windows rig run, and acceptance criteria.

Co-authored-by: Orca <help@stably.ai>

* feat(agent-hooks): agent status over WSL — guest relay + WSL-side hook installers (STA-1515)

Agent hooks have never worked from inside WSL: under default NAT
networking, WSL's 127.0.0.1 is its own loopback, so every hook POST to
the Windows listener dies silently, and hook configs were only ever
written to the Windows home where WSL agents never see them.

Transport: a hooks-only guest relay (src/relay/wsl-agent-hook-relay.ts)
runs inside the distro, binds WSL loopback on the very port the clients
were already given (host-issued token; EADDRINUSE falls back to :0 with
endpoint-file re-coordination, which also covers mirrored networking),
and forwards parsed envelopes over its own wsl.exe stdio into
agentHookServer.ingestRemote — the same shape as the SSH relay. It exits
when stdin closes so a freed Windows port can never be forwarded into a
dead guest listener.

Installation: the unchanged SSH remote hook installers run against an
SFTP-shaped adapter whose primitives are home-scoped fs RPCs served by
the relay, so all 14 managed agents' hooks land in the WSL home over the
already-open channel with zero per-file wsl.exe spawns.

Lifecycle: per-distro manager ensured from buildPtyHostEnv on every WSL
PTY spawn (covers post-restart daemon reattach re-spawns), stale-bundle
reinstall via exit 42, no-node-43 cooldown, bounded retry for wsl.exe
'Catastrophic failure (E_UNEXPECTED)', breadcrumbed failures.

Zero per-client transport changes; listener stays Windows-loopback-only.

Co-authored-by: Orca <help@stably.ai>

* fix(agent-hooks): WSL relay link-death recovery + Codex runtime-home hook install (STA-1515)

Follow-ups from the first Windows-rig validation of PR #7903:

Link death: a mux protocol error or keepalive timeout could kill the
host<->guest link while the guest relay stayed alive returning 204s —
the manager stayed 'running' and every later envelope blackholed
silently (the exact observed signature: Claude hooks POST 204, store
never populates). wsl-hook-relay-link.ts now guarantees exactly-once
death handling from either signal (mux dispose OR child exit); the
manager breadcrumbs it, kills the child, and self-restarts after a
short cooldown since a live agent session produces no new PTY spawns
to re-trigger ensure. ORCA_WSL_HOOK_RELAY_DEBUG=1 traces each received
envelope pre-ingest. A live integration test pins the full host chain:
the real esbuild bundle over real child stdio through the real manager
into a real AgentHookServer.ingestRemote, exact Claude POST shape.

Codex: Orca launches WSL Codex with CODEX_HOME redirected to the
managed runtime home (~/.local/share/orca/codex-runtime-home/home), so
hooks installed to ~/.codex were never read. installRemote now accepts
an explicit codex home (flat layout), threaded from the relay manager;
the config.toml trust write is deferred while the file doesn't exist
(the launch path seeds it only-if-absent — creating it first would
cancel the seed), and the manager re-runs the byte-equality-idempotent
installers on later ensures (30s throttle) to upsert trust once the
seed lands.

Also: WSL test suites now run on Windows dev hosts (fs-backed suites
skip with rig coverage noted; manager suite uses a fixed POSIX home).

Co-authored-by: Orca <help@stably.ai>

* fix(agent-hooks): renderer ownership gate treats wsl:* connection ids as local (STA-1515)

Round-2 rig finding: with the link fixed, WSL hook envelopes reached
ingestRemote and the durable cache, but useIpcEvents.applyAgentStatus
drops any status whose stamped connectionId differs from the owning
repo's — 'wsl:<distro>' !== null for a local repo, so every WSL-relayed
status died before setAgentStatus and notifications.

wsl:* ids are transport provenance, not ownership: the gate now
normalizes them to local via isWslHookRelayConnectionId (shared
contract, also used by the relay link when stamping), while still
rejecting WSL-stamped events against SSH-owned repos. Provenance stays
stamped — it is what made this drop diagnosable.

Co-authored-by: Orca <help@stably.ai>

* fix(agent-hooks): adversarial-review hardening for the WSL hook relay (STA-1515)

Four independent review lenses over the branch; all confirmed findings
fixed before the next rig round:

Endpoint identity (4/4 reviewers): the guest endpoint dir was keyed by
the EPHEMERAL Windows hook port, so a daemon-surviving agent kept
sourcing the dead port-P1 file after an Orca restart — breaking the
restart-resume acceptance criterion and regressing shipped OMP
recovery. Now keyed by a restart-stable instance key (hash of the
Windows endpoint file path, crossed via ORCA_WSL_HOOK_INSTANCE): the
restarted instance's relay rewrites the SAME file, which is exactly
what re-coordinates survivors.

Restart policy: every failure arms the restart timer (one failed
relaunch no longer ends self-recovery), and the timer probes
wsl --list --running first — wsl -d BOOTS a stopped distro, so
recovery must never resurrect a VM the user shut down; stopped-distro
state is dropped instead. Failure counters reset only after 2min of
stable uptime, so connect-then-die loops escalate to the 10-min cap
instead of cycling every 10s. Timer policy extracted to
wsl-hook-relay-recovery.ts with direct tests.

Also: version-namespaced guest install dir (dev+prod instances no
longer reinstall over each other; PID-suffixed tmp files), 30s install
timeout (a wedged wsl.exe could pin the state machine at 'starting'
forever), per-candidate node version probing (apt node 12 on PATH no
longer masks nvm node 20 into a false no-node cooldown), WSL_UTF8=1 +
NUL-stripped stderr (catastrophic-failure matcher survives UTF-16LE),
ordered post-sentinel chunk handoff, port-fallback breadcrumb via the
home handshake, bad home reply now fails the connect, missing-bundle
warn-once, case-normalized distro keys, disposeAll wired to will-quit,
one-shot 60s reinstall timer for single-spawn Codex trust catch-up,
escaped + contract-derived spawn command.

Co-authored-by: Orca <help@stably.ai>

* docs: record round-3 rig validation status for agent status over WSL (STA-1515)

Co-authored-by: Orca <help@stably.ai>

* fix(agent-hooks): round-4 adversarial-review fixes for the WSL hook relay (STA-1515)

- dropState identity race: recovery re-checks state identity after the
  distro-running probe await, and the manager's dropState only deletes the
  exact state it was armed for — an ensure() landing mid-probe can no longer
  have its fresh relay orphaned outside the map.
- Distro-running probe fails CLOSED: a probe error no longer reports
  'running', so recovery can never wsl-d-boot a distro the user shut down.
- Relay spawns use --exec: bypasses the distro's default login shell
  (fish/nushell chsh) and passes argv verbatim, dropping the $-escape shim;
  same form as the Codex WSL login spawn.
- Post-sentinel chunk handoff rides a microtask so an envelope in the
  trailing bytes can no longer dispatch before the link's notification
  handler is registered.
- Guest relay mirrors the SSH relay's uncaughtException/unhandledRejection
  posture.
- Replay cache capped at 256 panes with recency eviction (the WSL relay has
  no per-pane teardown signal); meta map kept in lockstep.
- Launch script derives the stale-exit code from the shared contract
  constant; one-shot reinstall timer refuses to arm after dispose.
- New oracles: sentinel unit suite, fs-bridge scoping suite, fixed-token
  403/204, EADDRINUSE endpoint-file rewrite, cache-cap eviction, and the
  recovery/manager race regressions (verified to fail with fixes reverted).
- Doc: round-4 review section + revised curl.exe stance (kept as the
  no-node fallback — Codex is a native binary; fresh distros ship no node).

* docs: record round-4 pinned rig validation for agent status over WSL (STA-1515)

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
This commit is contained in:
Brennan Benson 2026-07-10 00:19:45 -07:00 committed by GitHub
parent 1383ba85cf
commit f238952be2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 3600 additions and 25 deletions

View File

@ -59,4 +59,31 @@ for (const platform of PLATFORMS) {
console.log(`Built relay for ${platform}${outDir}/relay.js`)
}
// WSL agent-hook relay: a hooks-only guest receiver launched inside WSL
// distros via wsl.exe. Pure Node built-ins (no node-pty/@parcel/watcher),
// so a single platform-independent bundle suffices; it ships inside the
// Windows app via the same out/relay extraResources mapping.
{
const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
const outDir = join(ROOT, 'out', 'relay', 'wsl')
mkdirSync(outDir, { recursive: true })
await build({
entryPoints: [wslEntry],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'wsl-agent-hook-relay.js'),
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
const content = readFileSync(join(outDir, 'wsl-agent-hook-relay.js'))
const hash = createHash('sha256').update(content).digest('hex').slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`)
}
console.log('Relay build complete.')

View File

@ -0,0 +1,374 @@
# Agent Status over WSL (STA-1515)
Status: implemented and rig-validated (2026-07-09 round 3 + 2026-07-10 round-4 re-run
pinned to the hardened build, Windows 11 + WSL2 NAT): Claude end-to-end live —
provisioning, working→done in the store, completion toast, loopback-only posture, and
restart resume over a daemon-surviving PTY with the instance-keyed endpoint dir reused
across restarts. The round-4 re-run also proved the `--exec` spawn form live (host
process table) and the stale-exit reinstall upgrading the guest to the new bundle
version in place. Residual: Codex's done/Stop leg unproven live — env-blocked on the
rig (no dev-profile Codex credentials AND the model backend is unreachable from the
guest under NAT); everything that fired behaved correctly. Confirm on a credentialed
rig with a guest-reachable backend, ideally on a fresh distro to also observe the
deferred Codex trust entries landing after config.toml is seeded.
Owner: brennanb2025. Linear: STA-1515.
Precedent this mirrors: the SSH agent-hook relay (`src/relay/agent-hook-server.ts`,
`src/shared/agent-hook-relay.ts`, ingest at `agentHookServer.ingestRemote` in
`src/main/agent-hooks/server.ts`).
## Background — how we got here
GitHub issue `7565` reported OMP agents in WSL worktrees disappearing from the worktree
sidebar after v1.4.124. Diagnosis split it into a regression and a pre-existing class gap:
- The **regression** was a title-normalization change (PR `7447`) that stopped idle OMP
titles from producing the sidebar's title-derived fallback row. Decision: the sidebar is
moving to **hook-driven rows only** (fallback removal in flight, separate PR), so the
fallback was not restored.
- The **class gap** is that agent hooks have never worked from inside WSL for any agent.
Two scoped PRs fixed it for OMP alone (merged, live-validated on a Windows+WSL2-NAT rig):
- PR `7642` — Orca-managed WSL shells wrap interactive `omp` invocations with
`--extension "$ORCA_OMP_STATUS_EXTENSION"` (the env var is WSLENV `/p`-translated so
the WSL process reads the extension out of the Windows filesystem via `/mnt/c`).
- PR `7641` — when the extension's loopback POST cannot connect, it delivers via
Windows-side `/mnt/c/Windows/System32/curl.exe` (a Windows process, so *its*
`127.0.0.1` is the loopback Orca actually binds). Fire-and-forget spawn,
`--noproxy 127.0.0.1`, memoized WSL/curl probes, load-tolerant timeouts
(`--connect-timeout 3 --max-time 10`; 0.5s dropped events under load).
This document is the full context for the general fix: every other hook client is still
dead from WSL, and the hooks-only sidebar change makes this work the gate for the
Windows+WSL story.
## Why hooks don't work on Windows+WSL — two independent gaps
### Gap A — transport
The hook listener binds `127.0.0.1` only, deliberately (`src/main/agent-hooks/server.ts`,
`listen(0, '127.0.0.1')`; auth via `X-Orca-Agent-Hook-Token`, 403 otherwise). Every hook
client POSTs to a hardcoded `http://127.0.0.1:$ORCA_AGENT_HOOK_PORT/hook/<source>`.
WSL2 under default **NAT** networking is a VM with its own network namespace. Microsoft's
localhost forwarding is **one-way (Windows→WSL only)**: `127.0.0.1` inside WSL is WSL's
own loopback, so every POST dies `ECONNREFUSED` — silently, because hook clients are
deliberately fail-open. Reaching Windows from WSL would require the host vNIC IP (changes
per boot) + a non-loopback listener bind + a firewall rule — all three conflict with the
loopback-only security posture.
The env coordinates DO cross correctly (`src/main/pty/wsl-orca-env.ts`
`addOrcaWslInteropEnv`: WSLENV `PORT/u TOKEN/u ENV/u VERSION/u` plus
`ORCA_AGENT_HOOK_ENDPOINT/p` path-translated; called from `src/main/ipc/pty.ts` and
`src/main/daemon/pty-subprocess.ts`). The address is simply unreachable.
Opt-in **mirrored** networking (Win11, `.wslconfig`) shares loopback and makes plain fetch
work — the fix must not fight it. No `wslinfo` probing is needed: under mirrored mode the
relay's preferred-port bind collides with the Windows listener and the `EADDRINUSE`
fallback (below) handles it, while clients that go straight to the shared loopback reach
the Windows listener directly. Both delivery paths stay valid.
### Gap B — installation
Hook configs and scripts are written to the **Windows** home by every hook service:
Claude `settings.json` + managed scripts, Codex config, Gemini/Cursor/Droid/Devin/Grok/
Copilot scripts, Amp/OpenCode plugin files, the Pi/OMP extension file. An agent inside WSL
reads the **WSL-side** `$HOME` and sees none of it. There is zero WSL-targeted install
code in `src/main/agent-hooks/` or any hook service. SSH remotes have the exact precedent
needed: dedicated remote installers (`src/main/ssh/ssh-relay-session.ts` remote
settings.json handling; PR `7744` installed Droid/Copilot hooks over SSH).
Consequence: even mirrored-networking users get no hooks — transport fine, configs absent.
OMP escapes Gap B by *pointing across* the boundary (`/mnt/c` path via `/p` translation)
rather than installing WSL-side; that trick can carry file *content* for some clients, but
shell hooks still execute inside WSL and then hit Gap A regardless.
## Transport map — every client, how it posts
Endpoint file contract: `writeEndpointFile` (`src/shared/agent-hook-listener.ts`) emits
exactly four keys (`ORCA_AGENT_HOOK_PORT/TOKEN/ENV/VERSION`) to `endpoint.env` (POSIX) /
`endpoint.cmd` (Windows) — **no host field**. Shell clients source it to refresh stale
coords after an Orca restart; node clients parse it. It is never executed as a delivery
script. Clients prefer endpoint-FILE coords over env (restart re-coordination) — any
transport change must preserve that property.
| Client | Mechanism | Runtime that POSTs |
| --- | --- | --- |
| Claude, Codex, Gemini, Cursor, Droid, Devin, Grok | managed shell script | `curl` (POSIX) / `curl.exe` (Windows), built in `src/main/agent-hooks/installer-utils.ts` |
| Copilot | managed script | `curl` (POSIX) / PowerShell `Invoke-WebRequest` (Windows) |
| command-code | managed script (parse-not-source hardened) | `curl` |
| Amp, OpenCode | in-process node plugin | `fetch` |
| Pi / OMP | bundled in-process extension (`src/main/pi/agent-status-extension-source.ts`) | `fetch`, now with the WSL curl.exe fallback |
All of them target `127.0.0.1`.
## Status quo + why this gates the hooks-only sidebar
Today the worktree-card rows still have a title-derived fallback producer
(`src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts`), so WSL users
DO currently see rows for title-rich agents (Claude `✳`/spinner titles, Gemini glyphs) —
degraded (generic text, no prompt/last-message preview, no notifications) but present.
Title-poor agents are dark (Codex — hence GH `6907`). When the hooks-only change removes
that producer, **every non-OMP agent in a WSL worktree loses its card row entirely until
this work ships**. Hook fidelity adds: prompt + last-assistant-message previews,
waiting/blocked precision, completion notifications, AI Vault / native chat session
integration.
## Solution design — WSL relay + WSL-side installation
### Transport (Gap A): guest-resident relay, host-owned stdio
Run a small receiver **inside WSL on WSL's own loopback, listening on the very port the
clients were already given** (`$ORCA_AGENT_HOOK_PORT` — free inside WSL, since that port
only exists on the Windows side). Unmodified clients then deliver successfully with
**zero client changes**; the reporter's diagnostic relay in GH `7565` proved this shape
live. Forward each parsed envelope to the Windows host over the relay's **own stdio**
(Orca spawns it via `wsl.exe`, so it owns that pipe). Ingest through the existing trust
boundary: `agentHookServer.ingestRemote` (`src/main/agent-hooks/server.ts`), envelope
shape `src/shared/agent-hook-relay.ts` — identical to the SSH relay, which runs a
loopback-only receiver on the remote box and forwards over the SSH control channel.
**Port binding.** Bind the inherited `$ORCA_AGENT_HOOK_PORT` first — it keeps every
already-crossed coordinate (env and the `/p`-translated endpoint file) truthful with zero
divergence. On `EADDRINUSE` inside the guest, fall back to the SSH relay's own pattern:
bind `127.0.0.1:0`, write a **WSL-side endpoint file**, and point WSL PTYs'
`ORCA_AGENT_HOOK_ENDPOINT` at it (clients already prefer endpoint-file coords over env).
The relay writes that WSL-side endpoint file in **both** modes so restart re-coordination
never depends on `/mnt/c` translation being readable.
Lifecycle: one relay per distro **per Orca instance** (concurrent instances have distinct
ports, so guest listeners never collide); **ensure** — not just start — whenever a WSL PTY
exists: first spawn *and* daemon-PTY reattach after an Orca restart (WSL PTYs survive in
the daemon; the new instance has a new port + token and must respawn the relay before
surviving agents re-coordinate). The relay **exits when its stdin closes**: a lingering
guest listener would let WSL's own Windows→WSL forwarder grab the freed Windows-side port
and blackhole stale Windows-side hook posts. Restart if WSL restarts; token still
validated at the relay's HTTP receiver; harmless under mirrored networking (bind
fallback) and inert on non-WSL platforms (ensure only fires from WSL PTY spawns).
Reliability contract (invariant class `agent-session.hook-transport`): hook clients are
fail-open silent, so the relay must not be — spawn failures, `EADDRINUSE` fallback, and
forward errors each leave a diagnosable breadcrumb; the `wsl.exe` "Catastrophic failure
(E_UNEXPECTED)" retry is **bounded** with backoff, never a spawn loop. Oracle: provider-
contract tests with fault injection (stdin close → exit, occupied port → fallback + file
rewrite, envelope round-trip to `ingestRemote`) rather than end-to-end flows only.
Design notes from a survey of comparable WSL-capable tools (kept nameless per policy):
- Guest-resident component + host-owned channel + guest-side installation, explicitly
reusing the tool's SSH-remote machinery, **is the established pattern**. No surveyed
tool makes guest processes dial back to a Windows-localhost listener — the merged OMP
curl.exe stopgap is the outlier as a primary path (but see the round-4 revised
stance below: it survives as the no-node fallback).
- Prefer host-owned **stdio** over Windows→WSL localhost port forwarding (wslhost
forwarding is known-flaky under load; one surveyed tool dials the distro vNIC IP just to
avoid it — stdio sidesteps the question entirely).
- WSL offers no persistent control channel between separate `wsl.exe` invocations —
collapse the relay's ensure-installed + launch into **one idempotent script per spawn**.
- Install into the guest from inside the guest (download/extract in WSL) or stream the
binary through `wsl.exe` stdin — not by copying through `/mnt/c`.
### Installation (Gap B): WSL-side hook installers
Write agent hook configs/scripts into the WSL-side home, per agent, analogous to the SSH
remote installers — via `wsl.exe`-executed scripts (preferred, mirrors SSH most closely)
or `\\wsl.localhost\<distro>\...` writes. Without this half, the relay receives nothing:
the hook clients themselves are absent from the WSL filesystem.
## Alternatives considered (and why not)
1. **Endpoint-file host/URL field** — the file already crosses into WSL (`/p`-translated),
but clients read only PORT/TOKEN and hardcode the host, so all ~13 still need edits;
and a WSL-reachable listener bind breaks the loopback-only posture (LAN exposure,
firewall prompts).
2. **Replicate the curl.exe bridge per client** — the shell clients share ~2 generated
builders so it is cheaper than it sounds, but it is N point fixes, requires WSL interop
enabled (`/etc/wsl.conf` can disable it), pays a per-event process spawn (load-sensitive,
see validation facts), and keeps the ecosystem-outlier direction.
3. **Listener-side bind changes / rely on mirrored networking** — posture conflict /
opt-in only.
4. **OSC 9999 in-band status** (`src/shared/agent-status-osc.ts`, parsed per-pane in
`pty-transport.ts` and `orca-runtime.ts`) — zero-network and pane-attributed, but only
viable for in-process clients and carries status payloads, not the full hook event
vocabulary (prompts, tools, completion) — cannot replace the pipeline.
## Facts + gotchas from the 2026-07-08 Windows-rig validation
- curl.exe interop delivery works under NAT (shipped for OMP), but per-event process spawn
is load-sensitive: `--connect-timeout 0.5` dropped 3/3 events to a *healthy* listener
under load; fine at 3s. A resident relay avoids per-event spawns entirely.
- `wslinfo --networking-mode` distinguishes NAT vs mirrored.
- Clients prefer endpoint-FILE coords over env. Testing gotcha: unset
`ORCA_AGENT_HOOK_ENDPOINT` in synthetic tests or events go to the real running app.
- Server ingest silently drops paneKeys that are not `uuid:uuid`-shaped — use real-shaped
keys in synthetic validation.
- OMP is a Bun single-file binary; Bun's `node:child_process`/`fetch` compat held. Other
in-process clients run inside their agents' runtimes — verify per runtime.
- Environmental: fresh WSL 2.7.10 intermittently threw "Catastrophic failure
(E_UNEXPECTED)" from `wsl.exe -d <distro> -- bash -lc` under concurrent spawn load
(cleared by `wsl --terminate`). The relay spawn path should tolerate/retry this.
- Fork-PR CI runs sit in `action_required` until approved:
`gh api repos/stablyai/orca/actions/runs/<id>/approve -X POST`.
## Acceptance
On a default-config Windows 11 + WSL2 **NAT** machine: launch **Codex or Claude**
(explicitly not OMP) in a WSL worktree → live hook-driven worktree-card row with status
transitions and a completion notification; hook listener still bound to Windows loopback
only; zero per-client transport changes; hooks installed WSL-side automatically (no manual
config); harmless under mirrored networking and inert on non-WSL platforms. After an Orca
restart with the WSL agent still running (daemon-surviving PTY), status events resume
without relaunching the agent.
## 2026-07-09 Windows-rig validation follow-ups
The first live GUI run proved every mechanism in isolation but failed end-to-end, yielding
two fixes:
1. **Link death must be handled, not just child death.** A mux protocol error or timeout
can kill the host↔guest link while the guest process (and its 204-returning receiver)
stays alive — the exact observed signature: hooks POST 204, store never populates.
`wsl-hook-relay-link.ts` now guarantees exactly-once death handling from either signal;
the manager breadcrumbs it, kills the child, and self-restarts after a short cooldown
(a live agent session produces no new PTY spawns to re-trigger ensure).
`ORCA_WSL_HOOK_RELAY_DEBUG=1` traces every received envelope pre-ingest so a live rig
can pinpoint any residual drop. The full host chain is pinned by a live integration
test (real bundle over real child stdio through the real manager into a real
`ingestRemote`).
2. **(Round 2) The renderer's SSH-era ownership gate dropped `wsl:*` events.** With the
link fixed, envelopes reached `ingestRemote` and the durable cache, but
`useIpcEvents.applyAgentStatus` compares the stamped connectionId against the owning
repo's — `"wsl:<distro>" !== null` for a local repo, so every WSL-relayed status died
before `setAgentStatus`/notifications. Fix: `wsl:*` ids are transport provenance, not
ownership — the gate normalizes them to local (null) via
`isWslHookRelayConnectionId`, while still rejecting WSL-stamped events against
SSH-owned repos. Provenance stays stamped (it made the drop diagnosable in the first
place).
3. **Codex reads a redirected home.** Orca launches WSL Codex with `CODEX_HOME` pointed at
the managed runtime home (`~/.local/share/orca/codex-runtime-home/home`), so installing
hooks to `~/.codex` left Codex dark. The installers now accept an explicit codex home;
the trust write into `config.toml` is deferred while that file doesn't exist (the
launch path seeds it only-if-absent — creating it first would cancel the seed), and the
manager re-runs the idempotent installers on later ensures (throttled) to upsert trust
once the seed lands. Consequence: the very first WSL Codex session after a cold relay
may miss hooks; the next one has them.
## 2026-07-09 adversarial-review hardening (pre-rig round 3)
Four independent review lenses over the full diff; confirmed findings fixed:
- **Endpoint identity (all 4 reviewers)**: the guest endpoint dir was keyed by the
ephemeral Windows hook port, so a daemon-surviving agent kept sourcing the DEAD
`port-P1` file after an Orca restart — breaking the restart-resume acceptance criterion
and regressing shipped OMP recovery. Now keyed by a restart-stable instance key
(hash of the Windows endpoint file path = userData + namespace, crossed via
`ORCA_WSL_HOOK_INSTANCE`): the restarted instance's relay REWRITES the same file, which
is exactly what re-coordinates survivors.
- **Restart policy**: every failure now arms the restart timer (one failed relaunch no
longer ends self-recovery), and the timer probes `wsl --list --running` first — `wsl -d`
BOOTS a stopped distro, so recovery must never resurrect a VM the user shut down; a
stopped distro's state is dropped instead (next WSL terminal re-ensures). Failure
counters only reset after 2 min of stable uptime, so connect-then-die loops escalate to
the 10-min cap instead of cycling every 10s.
- **Install-dir versioning**: the guest install dir is namespaced by bundle version, so
concurrent Orca instances with different bundles (dev + prod) never reinstall over each
other; tmp files carry the guest PID. The install spawn also gained the 30s timeout it
was missing (a wedged wsl.exe could previously pin the state machine at 'starting'
forever).
- **Guest node resolution**: candidates (PATH, nvm glob, fixed paths) are each
version-probed, first pass wins — an apt node 12 on PATH no longer masks an nvm node 20
into a false "no node >= 18" 10-minute cooldown.
- **wsl.exe text handling**: `WSL_UTF8=1` on all spawns + NUL-stripping on stderr, so the
"Catastrophic failure" transient-retry matcher and breadcrumbs survive UTF-16LE output.
- Smaller: ordered post-sentinel chunk handoff (frame-decoder desync race), port-fallback
breadcrumb now reaches host logs via the home handshake, bad home reply fails the
connect (was: silently 'running' without installs), missing-bundle warn-once, distro
map keys case-normalized, `disposeAll` wired to app `will-quit`, single-spawn Codex
trust catch-up via a one-shot 60s reinstall timer.
Accepted gaps (reviewed, deliberately not addressed here): old version-namespaced
install dirs accrete across upgrades (~200KB each); an outdated running daemon
/p-translates the guest endpoint path until it restarts (hook scripts fall back to env
coords, which same-port binding keeps correct); `wslDistroCache` caches a transient
empty list for the app run (pre-existing semantics, now load-bearing for default-distro
resolution); default-distro resolution caches the first answer for the app run.
## 2026-07-09 round-4 external adversarial review
A second adversarial sweep (five independent lenses: guest relay + fs bridge, host
lifecycle state machine, app integration + renderer gate, design-vs-alternatives, and a
platform fact-check of every WSL claim). Design verdict: the guest-resident relay over
host-owned stdio is the right architecture — the zero-per-client-change chokepoint is
what the curl.exe alternative cannot match, and the lifecycle weight is inherent to any
guest-resident helper. Confirmed findings, all fixed on this branch:
- **`dropState` identity race (major)**: the recovery timer re-checked state identity
only BEFORE the async `wsl --list --running` probe; an ensure() landing during the
probe could get its fresh state deleted by key — orphaning a live relay child outside
the map (unkillable by `disposeAll`, duplicate relay on next ensure). Fixed: identity
re-check after the probe await + identity-guarded delete in the manager.
- **Distro-running probe failed OPEN**: any probe error (including its 10s timeout)
reported "running", so recovery could `wsl -d` — and thereby BOOT — a distro the user
shut down, in exactly the wedged-wsl.exe failure mode where the probe errors. Now
fails closed: drop the state; the next WSL PTY spawn re-ensures.
- **Spawn form hardened to `--exec`**: `wsl.exe -- <cmd>` routes through the distro's
default login shell (Microsoft docs: only `--exec` runs "without using the default
Linux shell"), so a fish/nushell chsh could mangle the launch; `--exec sh -c`/-`s`
bypasses it and passes argv verbatim (no `$`-preprocessing, escaping shim dropped) —
same form as the Codex WSL login spawn.
- **Post-sentinel handoff microtask**: pending chunks flushed synchronously inside the
mux constructor, before the manager could register notification handlers — an
envelope arriving in the trailing bytes dispatched to zero handlers (recovered only
by the later replay request). Flush now rides a microtask: after the caller's
synchronous wiring, still ahead of any subsequent stdout IO event.
- **Relay process posture**: the guest relay now mirrors the SSH relay's
`uncaughtException` (log + exit → manager respawns) / `unhandledRejection` (log +
survive) handlers.
- **Replay cache recency cap**: the WSL relay has no per-pane teardown signal, so the
per-pane replay cache grew for the relay's lifetime; now capped at 256 panes,
evicting longest-idle first (meta map kept in lockstep). Backstop for SSH too.
- Smaller: guest launch script derives the stale-exit code from the shared contract
constant (was a hardcoded 42 twin); one-shot reinstall timer refuses to arm after
dispose; fs-bridge scope comment states the lexical (symlink-following) bound
honestly. New oracles: sentinel unit suite (chunk splits, overflow kill, timeout,
microtask handoff), fs-bridge scoping suite, 403 + fallback endpoint-file rewrite,
cache-cap eviction, and the recovery/manager race regressions.
**Revised stance on the OMP curl.exe bridge — keep it, do not retire.** The relay
requires node ≥ 18 in the distro; a fresh WSL Ubuntu ships none, Codex CLI is a native
binary that brings none, and Claude Code's native installer no longer implies a system
node. A distro running only Codex would hit the no-node cooldown and stay dark — the
exact GH `6907` shape. The interop bridge is the one delivery path with no guest
runtime requirement, so it stays as the documented no-node fallback (currently wired
for OMP; extending it to the shared shell-script builders is the tracked follow-up if
no-node distros show up in telemetry). The relay remains the primary path: resident
(no per-event spawn cost) and interop-independent.
## Implementation map
- Guest: `src/relay/wsl-agent-hook-relay.ts` (entry; exits on stdin close),
`src/relay/wsl-hook-fs-bridge.ts` (home-scoped fs RPCs for installs),
`src/relay/agent-hook-server.ts` (`token`/`preferredPort` options + `EADDRINUSE`
fallback). Bundled by `config/scripts/build-relay.mjs``out/relay/wsl/`.
- Host: `src/main/agent-hooks/wsl-hook-relay-manager.ts` (per-distro state machine),
`wsl-hook-relay-launch.ts` (bundle resolve, guest launch/install scripts, spawn env,
sentinel wait), `wsl-hook-relay-link.ts` (envelope forward + exactly-once link-death
handling), `wsl-hook-relay-deps.ts` (DI seam), `wsl-hook-fs-adapter.ts` (SFTP-shaped
adapter + `installWslGuestHooks`, which targets Codex's managed runtime home).
- Wiring: `buildPtyHostEnv` (`src/main/ipc/pty.ts`) ensures the relay on every WSL spawn
and repoints `ORCA_AGENT_HOOK_ENDPOINT` at the guest endpoint file once known;
`src/main/pty/wsl-orca-env.ts` picks `/u` vs `/p` by value shape.
- Contract shared by both sides: `src/shared/wsl-hook-relay-contract.ts`.
- Oracles: `src/relay/wsl-agent-hook-relay.test.ts`,
`src/main/agent-hooks/wsl-hook-relay-manager.test.ts` (fault injection: stale-42
reinstall, no-node-43 cooldown, bounded E_UNEXPECTED retry, exit re-ensure gating, and a
full installer run against an in-memory guest).
## References
- GitHub: issues `6907` (Codex/WSL), `7091` + `7565` (OMP, fixed), `7563` (WSL CLI
detection, adjacent); PRs `7642` + `7641` (OMP fixes), `7744` (SSH hook installers
precedent), `7447` (title-collapse regression).
- Linear: STA-1515 (this work; ticket comments carry the same context).
- Key files: `src/main/agent-hooks/server.ts`, `src/shared/agent-hook-listener.ts`,
`src/shared/agent-hook-relay.ts`, `src/relay/agent-hook-server.ts`, `src/relay/relay.ts`,
`src/main/pty/wsl-orca-env.ts`, `src/main/agent-hooks/installer-utils.ts`,
`src/main/pi/agent-status-extension-source.ts`, `src/main/ssh/ssh-relay-session.ts`,
`src/main/providers/windows-shell-args.ts`, `src/shared/wsl-login-shell-command.ts`.

View File

@ -253,6 +253,48 @@ describe('remote hook service installers', () => {
expect(toml).toContain('trusted_hash = "sha256:')
})
it('installs Codex hooks into an explicit redirected CODEX_HOME (WSL managed runtime home)', async () => {
const runtimeHome = '/home/dev/.local/share/orca/codex-runtime-home/home'
const { sftp, fs } = createFakeSftp({
[`${runtimeHome}/config.toml`]: 'model = "gpt-5.2-codex"\n'
})
const status = await new CodexHookService().installRemote(sftp, '/home/dev', {
codexHomeDir: runtimeHome,
deferTrustUntilConfigToml: true
})
expect(status.state).toBe('installed')
expect(status.configPath).toBe(`${runtimeHome}/hooks.json`)
expect(fs.files.has('/home/dev/.codex/hooks.json')).toBe(false)
const hooks = JSON.parse(fs.files.get(`${runtimeHome}/hooks.json`)!) as {
hooks: Record<string, { hooks: { command: string }[] }[]>
}
expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain(
'/home/dev/.orca/agent-hooks/codex-hook.sh'
)
const toml = fs.files.get(`${runtimeHome}/config.toml`)
expect(toml).toContain('model = "gpt-5.2-codex"')
expect(toml).toContain(`${runtimeHome}/hooks.json:stop:0:0`)
})
it('defers Codex trust writes until the redirected config.toml exists (launch-path seed race)', async () => {
const runtimeHome = '/home/dev/.local/share/orca/codex-runtime-home/home'
const { sftp, fs } = createFakeSftp()
const status = await new CodexHookService().installRemote(sftp, '/home/dev', {
codexHomeDir: runtimeHome,
deferTrustUntilConfigToml: true
})
expect(status.state).toBe('installed')
expect(status.detail).toContain('deferred')
expect(fs.files.get(`${runtimeHome}/hooks.json`)).toContain('codex-hook.sh')
// Why: creating config.toml here would make the launch path's
// only-if-absent seed skip the user's real config.
expect(fs.files.has(`${runtimeHome}/config.toml`)).toBe(false)
})
it('reports Codex trust-write failures without rolling back installed hooks', async () => {
const { sftp, fs } = createFakeSftp()
fs.failRenameTo.add('/home/dev/.codex/config.toml')

View File

@ -15,15 +15,37 @@ import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
export type RemoteManagedHookInstallOptions = {
/** Explicit CODEX_HOME dir for redirected runtimes (WSL managed runtime
* home). Codex-only: it is the one agent whose home Orca redirects. Also
* defers the config.toml trust write until that file exists, so the
* launch path's only-if-absent seed is never pre-empted. */
codexHomeDir?: string
}
type RemoteManagedHookInstaller = readonly [
AgentHookInstallStatus['agent'],
(sftp: SFTPWrapper, remoteHome: string) => Promise<AgentHookInstallStatus>
(
sftp: SFTPWrapper,
remoteHome: string,
options?: RemoteManagedHookInstallOptions
) => Promise<AgentHookInstallStatus>
]
const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
['claude', (sftp, remoteHome) => claudeHookService.installRemote(sftp, remoteHome)],
['openclaude', (sftp, remoteHome) => openClaudeHookService.installRemote(sftp, remoteHome)],
['codex', (sftp, remoteHome) => codexHookService.installRemote(sftp, remoteHome)],
[
'codex',
(sftp, remoteHome, options) =>
codexHookService.installRemote(
sftp,
remoteHome,
options?.codexHomeDir
? { codexHomeDir: options.codexHomeDir, deferTrustUntilConfigToml: true }
: undefined
)
],
['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)],
['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)],
['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)],
@ -45,12 +67,13 @@ export const REMOTE_MANAGED_HOOK_INSTALLER_AGENTS: readonly AgentHookInstallStat
export async function installRemoteManagedAgentHooks(
sftp: SFTPWrapper,
remoteHome: string
remoteHome: string,
options?: RemoteManagedHookInstallOptions
): Promise<AgentHookInstallStatus[]> {
const results: AgentHookInstallStatus[] = []
for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) {
try {
const result = await install(sftp, remoteHome)
const result = await install(sftp, remoteHome, options)
results.push(result)
if (result.state === 'error') {
console.warn(

View File

@ -0,0 +1,136 @@
// SFTP-shaped adapter over the WSL hook relay's fs bridge. Lets the
// unchanged SSH remote hook installers (`installRemoteManagedAgentHooks`)
// write into a WSL distro's home over the relay's already-open stdio channel
// — the WSL twin of the SSH flow's real SFTPWrapper. Only the primitives
// `installer-utils-remote.ts` touches are implemented.
import type { SFTPWrapper } from 'ssh2'
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract'
/** Run the shared remote hook installers against a WSL guest over the relay's
* fs bridge. Codex is the one agent whose home Orca redirects for WSL
* sessions, so its hooks go to the managed runtime home. */
export async function installWslGuestHooks(options: {
mux: SshChannelMultiplexer
guestHome: string
distro: string
installHooks: typeof installRemoteManagedAgentHooks
warn: (message: string) => void
}): Promise<void> {
const { mux, guestHome, distro, installHooks, warn } = options
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome)
})
const failed = results.filter((r) => r.state === 'error').length
if (failed > 0) {
warn(
`[agent-hooks] WSL hook install for '${distro}': ${failed}/${results.length} agents failed`
)
}
}
type SftpCallback<T = void> = (err: Error | null, value?: T) => void
// Why: installer-utils-remote classifies errors by ssh2's numeric SFTP status
// codes (ENOENT=2, already-exists=4). Map guest POSIX errno onto those so the
// shared classifiers keep working across transports.
const ERRNO_TO_SFTP_CODE: Record<string, number> = {
ENOENT: 2,
ENOTDIR: 2,
EACCES: 3,
EEXIST: 4
}
function toSftpError(failure: { errno?: string; message?: string }): Error {
const err = new Error(failure.message ?? 'wsl fs bridge failure') as Error & { code?: number }
err.code = ERRNO_TO_SFTP_CODE[failure.errno ?? ''] ?? 5
return err
}
export function createWslHookSftpAdapter(mux: SshChannelMultiplexer): SFTPWrapper {
const call = <Wire extends object, Value>(
method: string,
params: Record<string, unknown>,
callback: SftpCallback<Value>,
pick: (result: { ok: true } & Wire) => Value
): void => {
mux
.request(method, params)
.then((raw) => {
const result = raw as WslFsResult<Wire>
if (!result || typeof result !== 'object' || result.ok !== true) {
callback(toSftpError((result ?? {}) as { errno?: string; message?: string }))
return
}
callback(null, pick(result))
})
.catch((err) => callback(err instanceof Error ? err : new Error(String(err))))
}
const callVoid = (
method: string,
params: Record<string, unknown>,
callback: SftpCallback
): void => {
call<Record<string, never>, undefined>(method, params, callback, () => undefined)
}
const adapter = {
readFile(path: string, _encoding: unknown, callback: SftpCallback<string>): void {
call<{ content: string }, string>(
WSL_HOOK_FS_METHODS.readFile,
{ path },
callback,
(r) => r.content
)
},
writeFile(
path: string,
content: string,
options: { mode?: number },
callback: SftpCallback
): void {
callVoid(WSL_HOOK_FS_METHODS.writeFile, { path, content, mode: options?.mode }, callback)
},
stat(path: string, callback: SftpCallback<{ mode: number }>): void {
call<{ mode: number }, { mode: number }>(
WSL_HOOK_FS_METHODS.stat,
{ path },
callback,
(r) => ({
mode: r.mode
})
)
},
// Why: POSIX rename overwrites atomically, which is exactly the OpenSSH
// overwrite-rename semantics the installers prefer — so the extension is
// "supported" here and plain rename shares the implementation.
ext_openssh_rename(src: string, dst: string, callback: SftpCallback): void {
callVoid(WSL_HOOK_FS_METHODS.rename, { src, dst }, callback)
},
rename(src: string, dst: string, callback: SftpCallback): void {
callVoid(WSL_HOOK_FS_METHODS.rename, { src, dst }, callback)
},
unlink(path: string, callback: SftpCallback): void {
callVoid(WSL_HOOK_FS_METHODS.unlink, { path }, callback)
},
chmod(path: string, mode: number, callback: SftpCallback): void {
callVoid(WSL_HOOK_FS_METHODS.chmod, { path, mode }, callback)
},
readdir(path: string, callback: SftpCallback<{ filename: string }[]>): void {
call<{ entries: { filename: string }[] }, { filename: string }[]>(
WSL_HOOK_FS_METHODS.readdir,
{ path },
callback,
(r) => r.entries
)
},
mkdir(path: string, callback: SftpCallback): void {
callVoid(WSL_HOOK_FS_METHODS.mkdir, { path }, callback)
}
}
return adapter as unknown as SFTPWrapper
}

View File

@ -0,0 +1,90 @@
// DI seam for WslHookRelayManager: the full dependency contract plus the
// production wiring. Tests construct the manager with fakes for everything
// that spawns wsl.exe or touches the live agentHookServer.
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { agentHookServer } from './server'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import {
isWslDistroRunning,
resolveWslHookRelayBundle,
runWslInstallProcess,
spawnWslRelayProcess
} from './wsl-hook-relay-launch'
import { waitForWslRelaySentinel } from './wsl-hook-relay-sentinel'
import { listWslDistrosAsync } from '../wsl'
import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay'
// Why: fresh WSL intermittently throws "Catastrophic failure (E_UNEXPECTED)"
// under concurrent wsl.exe spawn load; the retry pause is a dep so tests can
// collapse it.
export const WSL_RELAY_TRANSIENT_RETRY_DELAY_MS = 2_000
// Restart/cooldown policy for the manager's state machine.
export const FAILURE_COOLDOWN_BASE_MS = 60_000
export const FAILURE_COOLDOWN_MAX_MS = 10 * 60_000
// Why: a distro without node >= 18 will not grow one mid-session; probe
// rarely instead of once per PTY spawn.
export const NO_NODE_COOLDOWN_MS = 10 * 60_000
// Why: a previously-healthy relay dying mid-session (mux protocol error, WSL
// restart) must self-recover — a live agent session produces no new PTY
// spawns, so waiting for the next ensure would leave status dead for good.
export const RUNNING_TEARDOWN_COOLDOWN_MS = 10_000
// Why: only a relay that stayed up this long resets the failure counter — a
// connect-then-crash loop must keep escalating its cooldown, not sit at the
// running-teardown base forever.
export const STABLE_UPTIME_MS = 2 * 60_000
// Why: re-running the (byte-equality idempotent) installers picks up configs
// that appear after first install — e.g. Codex's runtime-home config.toml is
// seeded by the launch path, so its hook-trust entries can only be written
// once that file exists. The one-shot timer covers single-spawn sessions.
export const REINSTALL_MIN_INTERVAL_MS = 30_000
export const REINSTALL_ONE_SHOT_DELAY_MS = 60_000
export type WslHookRelayManagerDeps = {
platform: () => NodeJS.Platform
remoteHooksEnabled: () => boolean
hookCoordsEnv: () => Record<string, string>
/** Restart-stable, instance-unique key for the guest endpoint dir. */
instanceKey: () => string | null
resolveBundle: typeof resolveWslHookRelayBundle
readBundle: (jsPath: string) => Buffer
listDistros: () => Promise<string[]>
isDistroRunning: typeof isWslDistroRunning
spawnRelay: typeof spawnWslRelayProcess
runInstall: typeof runWslInstallProcess
waitForSentinel: typeof waitForWslRelaySentinel
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
installHooks: typeof installRemoteManagedAgentHooks
warn: (message: string) => void
transientRetryDelayMs: number
}
export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
platform: () => process.platform,
remoteHooksEnabled: () => isRemoteAgentHooksEnabled(),
hookCoordsEnv: () => agentHookServer.buildPtyEnv(),
// Why: the Windows endpoint file path (userData + namespace) is stable
// across app restarts and distinct per instance — exactly the identity the
// guest endpoint dir must carry so surviving agents re-coordinate.
instanceKey: () => {
const source = agentHookServer.endpointFilePath
return source ? createHash('sha256').update(source).digest('hex').slice(0, 12) : null
},
resolveBundle: resolveWslHookRelayBundle,
readBundle: (jsPath) => readFileSync(jsPath),
listDistros: () => listWslDistrosAsync(),
isDistroRunning: isWslDistroRunning,
spawnRelay: spawnWslRelayProcess,
runInstall: runWslInstallProcess,
waitForSentinel: waitForWslRelaySentinel,
ingest: (envelope, connectionId) =>
agentHookServer.ingestRemote(
envelope as Parameters<typeof agentHookServer.ingestRemote>[0],
connectionId
),
installHooks: installRemoteManagedAgentHooks,
warn: (message) => console.warn(message),
transientRetryDelayMs: WSL_RELAY_TRANSIENT_RETRY_DELAY_MS
}

View File

@ -0,0 +1,330 @@
// Launch/install plumbing for the guest-resident WSL agent-hook relay:
// bundle resolution on the Windows side, the guest launch/install scripts,
// and the sentinel wait that turns a wsl.exe child's stdio into a
// MultiplexerTransport. Kept separate from the manager so the state machine
// stays readable. See docs/agent-status-over-wsl.md (STA-1515).
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { app } from 'electron'
import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import {
decodeWslText,
MAX_STARTUP_BUFFER_BYTES,
type waitForWslRelaySentinel,
type WslRelayStartupFailure
} from './wsl-hook-relay-sentinel'
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import {
WSL_HOOK_RELAY_BUNDLE_NAME,
WSL_HOOK_RELAY_DIR,
WSL_HOOK_RELAY_INSTANCE_ENV,
WSL_HOOK_RELAY_NO_NODE_EXIT_CODE,
WSL_HOOK_RELAY_STALE_EXIT_CODE,
WSL_HOOK_RELAY_VERSION_ENV,
WSL_HOOK_RELAY_VERSION_FILE
} from '../../shared/wsl-hook-relay-contract'
const INSTALL_TIMEOUT_MS = 30_000
export type WslHookRelayBundle = { jsPath: string; version: string }
export function resolveWslHookRelayBundle(): WslHookRelayBundle | null {
// Mirrors getLocalRelayCandidates in ssh-relay-deploy: env override for
// tests/dev, then packaged extraResources, then dev out/ paths.
const candidates: string[] = []
if (process.env.ORCA_RELAY_PATH) {
candidates.push(join(process.env.ORCA_RELAY_PATH, 'wsl'))
}
if (process.resourcesPath) {
candidates.push(join(process.resourcesPath, 'relay', 'wsl'))
candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', 'wsl'))
}
try {
const appPath = app.getAppPath()
candidates.push(join(appPath, 'resources', 'relay', 'wsl'))
candidates.push(join(appPath, 'out', 'relay', 'wsl'))
} catch {
// app not ready in some test contexts — env/resources candidates suffice.
}
for (const dir of candidates) {
const jsPath = join(dir, WSL_HOOK_RELAY_BUNDLE_NAME)
const versionPath = join(dir, WSL_HOOK_RELAY_VERSION_FILE)
if (existsSync(jsPath) && existsSync(versionPath)) {
const version = readFileSync(versionPath, 'utf8').trim()
// Why: the version lands inside single-quoted guest shell text and in
// a guest path segment — refuse anything outside the safe alphabet.
if (/^[A-Za-z0-9+.-]+$/.test(version)) {
return { jsPath, version }
}
}
}
return null
}
// Why: the install dir is namespaced by bundle version so concurrent Orca
// instances with different bundles (dev + prod) never reinstall over each
// other; each instance launches exactly the version it shipped.
function guestRelayDirExpr(version: string): string {
return `$HOME/${WSL_HOOK_RELAY_DIR}/${version}`
}
/** Guest launcher, installed alongside the bundle. The `.version` marker is
* written last by the installer, so the check rejects partial installs;
* node resolution probes each candidate's version because `sh -c` does not
* source interactive profiles (an apt node 12 on PATH must not shadow an
* nvm node 20 off PATH). */
export function buildGuestLaunchScript(version: string): string {
const dir = guestRelayDirExpr(version)
return [
'#!/bin/sh',
`d="${dir}"`,
`v="$(cat "$d/${WSL_HOOK_RELAY_VERSION_FILE}" 2>/dev/null || true)"`,
`[ -n "$${WSL_HOOK_RELAY_VERSION_ENV}" ] && [ "$v" = "$${WSL_HOOK_RELAY_VERSION_ENV}" ] || exit ${WSL_HOOK_RELAY_STALE_EXIT_CODE}`,
'n=""',
'for c in "$(command -v node 2>/dev/null || true)" "$HOME/.nvm/versions/node"/*/bin/node /usr/local/bin/node /usr/bin/node "$HOME/.local/bin/node"; do',
' [ -n "$c" ] && [ -x "$c" ] || continue',
` if "$c" -e 'process.exit(Number(process.versions.node.split(".")[0])>=18?0:1)' 2>/dev/null; then`,
' n="$c"',
' break',
' fi',
'done',
`[ -n "$n" ] || exit ${WSL_HOOK_RELAY_NO_NODE_EXIT_CODE}`,
`exec "$n" "$d/${WSL_HOOK_RELAY_BUNDLE_NAME}"`,
''
].join('\n')
}
/** Idempotent install script, piped to `sh -s` over stdin. Heredocs with
* quoted delimiters carry the bundle (base64) and launcher verbatim, so no
* argv quoting crosses the wsl.exe boundary. Tmp names carry the guest PID
* so same-version concurrent installs cannot corrupt each other. */
export function buildGuestInstallScript(bundleJs: Buffer, version: string): string {
const b64 = bundleJs.toString('base64').replace(/(.{1,120})/g, '$1\n')
return [
'set -e',
'umask 077',
`d="${guestRelayDirExpr(version)}"`,
'mkdir -p "$d"',
`base64 -d > "$d/bundle.$$.tmp" << 'ORCA_EOF_BUNDLE'`,
b64.trimEnd(),
'ORCA_EOF_BUNDLE',
`mv "$d/bundle.$$.tmp" "$d/${WSL_HOOK_RELAY_BUNDLE_NAME}"`,
`cat > "$d/launch.$$.tmp" << 'ORCA_EOF_LAUNCH'`,
buildGuestLaunchScript(version).trimEnd(),
'ORCA_EOF_LAUNCH',
'mv "$d/launch.$$.tmp" "$d/launch.sh"',
'chmod 700 "$d/launch.sh"',
// Version marker last: a partial install stays "stale" and reinstalls.
`printf '%s' '${version}' > "$d/${WSL_HOOK_RELAY_VERSION_FILE}"`,
''
].join('\n')
}
export function spawnWslRelayProcess(
distro: string,
env: NodeJS.ProcessEnv,
version: string
): ChildProcessWithoutNullStreams {
// Why: --exec bypasses the distro's default login shell — a bare `--`
// routes through it (a fish/nushell chsh could mangle the command) and
// triggers wsl.exe's `$`-preprocessing of Windows argv. --exec passes argv
// verbatim (same form as the Codex WSL login spawn), so `$HOME` reaches
// sh unescaped and expands guest-side.
const command = `exec sh "${guestRelayDirExpr(version)}/launch.sh"`
return spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-c', command], {
env,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
}
/** True when the distro shows in `wsl --list --running`. Listing does NOT
* boot anything unlike `wsl -d`, which starts a stopped distro. The
* restart timer must check this so relay recovery never resurrects a VM the
* user shut down with `wsl --shutdown`. Fails CLOSED (false) on probe
* errors: booting a VM the user shut down is worse than a skipped restart
* (the next WSL PTY spawn re-ensures), and a wsl.exe too wedged to list
* distros would not have launched the relay anyway. */
export function isWslDistroRunning(distro: string): Promise<boolean> {
return new Promise((resolve) => {
execFile(
'wsl.exe',
['--list', '--running', '--quiet'],
// Why: WSL_UTF8=1 forces UTF-8 output; without it wsl.exe emits
// UTF-16LE that reads as NUL-riddled text.
{ env: { ...process.env, WSL_UTF8: '1' }, timeout: 10_000, windowsHide: true },
(err, stdout) => {
if (err) {
resolve(false)
return
}
const wanted = distro.trim().toLowerCase()
const running = decodeWslText(String(stdout))
.split(/\r?\n/)
.map((line) => line.trim().toLowerCase())
.filter(Boolean)
resolve(running.includes(wanted))
}
)
})
}
export function runWslInstallProcess(
distro: string,
script: string,
env: NodeJS.ProcessEnv
): Promise<{ code: number | null; stderr: string }> {
return new Promise((resolve, reject) => {
// Why: --exec skips the default login shell; the script rides stdin so
// no quoting crosses the wsl.exe boundary at all.
const child = spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-s'], {
env,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
let stderr = ''
let settled = false
// Why: a wedged wsl.exe here would otherwise pin the manager's state at
// 'starting' forever — the one unbounded await on the ensure path.
const timeout = setTimeout(() => {
if (!settled) {
settled = true
child.kill()
resolve({
code: null,
stderr: `${stderr}\ninstall timed out after ${INSTALL_TIMEOUT_MS}ms`
})
}
}, INSTALL_TIMEOUT_MS)
child.stderr.on('data', (d: Buffer) => {
stderr = (stderr + decodeWslText(d.toString('utf8'))).slice(-MAX_STARTUP_BUFFER_BYTES)
})
child.on('error', (err) => {
if (!settled) {
settled = true
clearTimeout(timeout)
reject(err)
}
})
child.on('close', (code) => {
if (!settled) {
settled = true
clearTimeout(timeout)
resolve({ code, stderr })
}
})
child.stdin.on('error', () => {
// Guest exited before consuming stdin — surfaced via close/code.
})
child.stdin.write(script)
child.stdin.end()
})
}
const TRANSIENT_RETRY_LIMIT = 2
export type WslRelayLaunchIo = {
spawnRelay: typeof spawnWslRelayProcess
waitForSentinel: typeof waitForWslRelaySentinel
runInstall: typeof runWslInstallProcess
readBundle: (jsPath: string) => Buffer
transientRetryDelayMs: number
}
/** Spawn sentinel connect, with the guest-install/retry policy: stale or
* missing installs get exactly one streamed reinstall, wsl.exe's transient
* "Catastrophic failure (E_UNEXPECTED)" gets a bounded retry, a distro
* without node >= 18 reports through `onNoNode`. Terminal failures report
* through `onFailure`; non-startup errors propagate to the caller. */
export async function launchWslRelayWithInstall(options: {
distro: string
env: NodeJS.ProcessEnv
bundleJsPath: string
version: string
io: WslRelayLaunchIo
isDisposed: () => boolean
onChild: (child: ChildProcessWithoutNullStreams) => void
onNoNode: () => void
onFailure: (message: string) => void
connect: (transport: MultiplexerTransport, child: ChildProcessWithoutNullStreams) => Promise<void>
}): Promise<void> {
const { distro, env, bundleJsPath, version, io } = options
let installTried = false
let transientRetries = 0
for (;;) {
if (options.isDisposed()) {
return
}
const child = io.spawnRelay(distro, env, version)
options.onChild(child)
try {
const transport = await io.waitForSentinel(child)
await options.connect(transport, child)
return
} catch (err) {
const failure = (err as { startup?: WslRelayStartupFailure }).startup
if (!failure) {
throw err
}
if (failure.code === WSL_HOOK_RELAY_NO_NODE_EXIT_CODE) {
options.onNoNode()
return
}
if (
/catastrophic failure/i.test(failure.stderr) &&
transientRetries < TRANSIENT_RETRY_LIMIT
) {
transientRetries++
await new Promise((resolve) => setTimeout(resolve, io.transientRetryDelayMs))
continue
}
if (!installTried) {
installTried = true
const script = buildGuestInstallScript(io.readBundle(bundleJsPath), version)
const result = await io.runInstall(distro, script, env)
if (result.code === 0) {
continue
}
options.onFailure(
`guest install failed (code ${result.code ?? 'unknown'}): ${result.stderr.trim()}`
)
return
}
options.onFailure(formatWslRelayFailure(failure))
return
}
}
}
export function formatWslRelayFailure(failure: WslRelayStartupFailure): string {
const detail = failure.stderr.trim()
return `startup failed (${failure.kind}, code ${failure.code ?? 'unknown'})${detail ? `: ${detail}` : ''}`
}
/** Env for the relay's wsl.exe spawn: the live hook coordinates, the
* host-expected bundle version, and the stable instance key, all crossed
* via WSLENV. WSL_UTF8 keeps wsl.exe's own error text (e.g. "Catastrophic
* failure") UTF-8 so stderr matching and breadcrumbs stay readable. */
export function buildWslRelaySpawnEnv(
coords: Record<string, string>,
bundleVersion: string,
instanceKey: string
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
WSL_UTF8: '1',
ORCA_AGENT_HOOK_PORT: coords.ORCA_AGENT_HOOK_PORT,
ORCA_AGENT_HOOK_TOKEN: coords.ORCA_AGENT_HOOK_TOKEN,
ORCA_AGENT_HOOK_ENV: coords.ORCA_AGENT_HOOK_ENV,
ORCA_AGENT_HOOK_VERSION: coords.ORCA_AGENT_HOOK_VERSION,
[WSL_HOOK_RELAY_VERSION_ENV]: bundleVersion,
[WSL_HOOK_RELAY_INSTANCE_ENV]: instanceKey
}
// Why: the relay derives its own guest endpoint path; a /p-translated
// Windows endpoint here would only add WSLENV noise.
delete env.ORCA_AGENT_HOOK_ENDPOINT
addOrcaWslInteropEnv(env as Record<string, string>)
return env
}

View File

@ -0,0 +1,58 @@
// Envelope forwarding and death handling for one live WSL relay link.
// Extracted from the manager so its state machine stays readable; the manager
// decides what a dead link means (cooldown, restart), this module guarantees
// it finds out exactly once, whichever signal fires first.
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { AGENT_HOOK_NOTIFICATION_METHOD } from '../../shared/agent-hook-relay'
import { wslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract'
export type WslRelayLinkOptions = {
mux: SshChannelMultiplexer
child: ChildProcessWithoutNullStreams
distro: string
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
warn: (message: string) => void
/** Called exactly once when the link dies from EITHER a mux dispose
* (protocol error, keepalive timeout) or the child exiting. A mux death
* without child death would otherwise blackhole every later envelope
* while the guest keeps returning 204 to hook clients. */
onDead: (reason: string) => void
}
export function wireWslRelayLink(options: WslRelayLinkOptions): void {
const { mux, child, distro, ingest, warn, onDead } = options
const connectionId = wslHookRelayConnectionId(distro)
mux.onNotification((method, params) => {
if (method !== AGENT_HOOK_NOTIFICATION_METHOD) {
return
}
if (typeof (params as { paneKey?: unknown }).paneKey !== 'string') {
return
}
if (process.env.ORCA_WSL_HOOK_RELAY_DEBUG === '1') {
const p = params as { paneKey?: string; payload?: { state?: string } }
warn(
`[agent-hooks] WSL relay envelope (${distro}): pane=${p.paneKey} state=${p.payload?.state ?? '?'}`
)
}
// Trust boundary: ingestRemote re-validates paneKey/tabId and
// re-normalizes the payload, same as the SSH relay path.
ingest(params, connectionId)
})
let dead = false
const die = (reason: string): void => {
if (dead) {
return
}
dead = true
mux.dispose()
child.kill()
onDead(reason)
}
mux.onDispose((reason) => die(`mux disposed (${reason})`))
child.on('close', () => die('process exited'))
}

View File

@ -0,0 +1,203 @@
// Live end-to-end oracle for the WSL hook relay HOST side: the real esbuild
// bundle runs as a real child process (spawned via `node` instead of wsl.exe
// — everything else identical), the real manager connects over the child's
// actual stdio pipes, the real installers write through the fs bridge, and a
// real HTTP POST in the exact Claude hook shape must land in a real
// AgentHookServer.ingestRemote. This is the chain the Windows-rig GUI run
// exercises minus the wsl.exe byte transport (validated separately on-rig).
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { AgentHookServer } from './server'
import { WslHookRelayManager } from './wsl-hook-relay-manager'
const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl')
const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js')
const LEAF = '11111111-1111-4111-8111-111111111111'
async function pickFreePort(): Promise<number> {
const probe = createServer()
const port = await new Promise<number>((resolve) => {
probe.listen(0, '127.0.0.1', () => {
const address = probe.address()
resolve(typeof address === 'object' && address ? address.port : 0)
})
})
await new Promise<void>((resolve) => probe.close(() => resolve()))
return port
}
// Why skipIf: the guest is always POSIX — the bundle derives its home from
// $HOME, which os.homedir() ignores on Windows. Windows coverage comes from
// the live rig runs against a real distro.
describe.skipIf(process.platform === 'win32')(
'WSL hook relay live host chain (real bundle over real child stdio)',
() => {
let fakeHome: string
let manager: WslHookRelayManager | null
let orcaServer: AgentHookServer | null
let child: ChildProcessWithoutNullStreams | null
beforeAll(() => {
if (!existsSync(BUNDLE_JS)) {
execFileSync(process.execPath, [join('config', 'scripts', 'build-relay.mjs')], {
cwd: process.cwd(),
stdio: 'ignore'
})
}
}, 120_000)
afterEach(() => {
manager?.disposeAll()
orcaServer?.stop()
child?.kill()
rmSync(fakeHome, { recursive: true, force: true })
})
it('delivers a Claude hook POST from the live relay into ingestRemote and installs guest hooks', async () => {
fakeHome = mkdtempSync(join(tmpdir(), 'wsl-live-home-'))
const preferredPort = await pickFreePort()
const version = readFileSync(join(BUNDLE_DIR, '.version'), 'utf8').trim()
orcaServer = new AgentHookServer()
const events: { paneKey: string; payload: unknown; connectionId: string | null }[] = []
orcaServer.setListener((event) => {
events.push({
paneKey: event.paneKey,
payload: event.payload,
connectionId: event.connectionId
})
})
const server = orcaServer
const warns: string[] = []
manager = new WslHookRelayManager({
platform: () => 'win32',
remoteHooksEnabled: () => true,
hookCoordsEnv: () => ({
ORCA_AGENT_HOOK_PORT: String(preferredPort),
ORCA_AGENT_HOOK_TOKEN: 'live-token',
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: '1'
}),
instanceKey: () => 'liveinstance',
resolveBundle: () => ({ jsPath: BUNDLE_JS, version }),
listDistros: async () => ['LiveDistro'],
spawnRelay: (_distro, env) => {
child = spawn(process.execPath, [BUNDLE_JS], {
env: { ...env, HOME: fakeHome },
stdio: ['pipe', 'pipe', 'pipe']
}) as ChildProcessWithoutNullStreams
return child
},
runInstall: async () => {
throw new Error('guest install must not run for a direct node spawn')
},
ingest: (envelope, connectionId) =>
server.ingestRemote(
envelope as Parameters<AgentHookServer['ingestRemote']>[0],
connectionId
),
warn: (message) => warns.push(message),
transientRetryDelayMs: 1
})
manager.ensureForDistro('LiveDistro')
// Codex hooks land in the redirected managed runtime home. Waiting on
// this artifact (not Claude's, which is written first) keeps the
// assertions behind the still-running 14-agent installer loop.
const codexRuntimeHome = join(
fakeHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
await vi.waitFor(() => expect(existsSync(join(codexRuntimeHome, 'hooks.json'))).toBe(true), {
timeout: 15_000
})
expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true)
const claudeScript = readFileSync(
join(fakeHome, '.orca', 'agent-hooks', 'claude-hook.sh'),
'utf8'
)
expect(claudeScript).toContain('/hook/claude')
// Trust TOML is deferred so the launch-path seed is never pre-empted.
expect(existsSync(join(codexRuntimeHome, 'config.toml'))).toBe(false)
expect(existsSync(join(fakeHome, '.codex', 'hooks.json'))).toBe(false)
// Re-coordinate exactly like a hook script: read the relay-written
// endpoint file rather than assuming the preferred port bind won.
const endpointFile = join(
fakeHome,
'.orca-wsl',
'agent-hooks',
'instance-liveinstance',
'endpoint.env'
)
expect(existsSync(endpointFile)).toBe(true)
const endpointText = readFileSync(endpointFile, 'utf8')
const port = Number(/ORCA_AGENT_HOOK_PORT=['"]?(\d+)/.exec(endpointText)?.[1])
const token = /ORCA_AGENT_HOOK_TOKEN=['"]?([A-Za-z0-9-]+)/.exec(endpointText)?.[1]
expect(port).toBeGreaterThan(0)
expect(token).toBe('live-token')
const paneKey = `tab-live:${LEAF}`
const postClaude = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token ?? ''
},
body: JSON.stringify({
paneKey,
tabId: 'tab-live',
worktreeId: 'wt-live',
env: 'remote',
version: '1',
payload
})
})
const promptRes = await postClaude({
hook_event_name: 'UserPromptSubmit',
prompt: 'live roundtrip'
})
expect(promptRes.status).toBe(204)
await vi.waitFor(() => expect(events.length).toBeGreaterThan(0), { timeout: 10_000 })
expect(events[0].paneKey).toBe(paneKey)
expect(events[0].connectionId).toBe('wsl:LiveDistro')
const working = events[0].payload as { state: string; prompt: string; agentType: string }
expect(working.state).toBe('working')
expect(working.prompt).toBe('live roundtrip')
expect(working.agentType).toBe('claude')
const stopRes = await postClaude({ hook_event_name: 'Stop' })
expect(stopRes.status).toBe(204)
await vi.waitFor(
() => {
const done = events.find((e) => (e.payload as { state?: string }).state === 'done')
expect(done).toBeTruthy()
},
{ timeout: 10_000 }
)
// Link death must be breadcrumbed and scheduled for restart — a silent
// mux/child death would blackhole every later envelope while the guest
// keeps returning 204 (the exact failure signature from the Windows rig).
child?.kill()
await vi.waitFor(
() => expect(warns.some((w) => w.includes('scheduling restart'))).toBe(true),
{ timeout: 10_000 }
)
}, 40_000)
}
)

View File

@ -0,0 +1,389 @@
// WSL hook relay host side: the SFTP-shaped fs adapter over the relay's fs
// bridge (including a full run of the unchanged remote hook installers), and
// the per-distro relay manager state machine with fault injection.
import { EventEmitter } from 'node:events'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RelayDispatcher } from '../../relay/dispatcher'
import { registerWslHookFsHandlers } from '../../relay/wsl-hook-fs-bridge'
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import { createWslHookSftpAdapter } from './wsl-hook-fs-adapter'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import { WslHookRelayManager } from './wsl-hook-relay-manager'
import { FAILURE_COOLDOWN_BASE_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps'
import {
AGENT_HOOK_NOTIFICATION_METHOD,
AGENT_HOOK_REQUEST_REPLAY_METHOD
} from '../../shared/agent-hook-relay'
type GuestHarness = {
transport: MultiplexerTransport
guestDispatcher: RelayDispatcher
mux: SshChannelMultiplexer
}
/** In-memory stdio pair: host mux on one end, guest dispatcher on the other
* (same harness shape as the relay agent-hook integration test). */
function createGuestHarness(): GuestHarness {
let relayFeed: ((data: Buffer) => void) | undefined
const clientDataCallbacks: ((data: Buffer) => void)[] = []
const closeCallbacks: (() => void)[] = []
const transport: MultiplexerTransport = {
write: (data) => {
setImmediate(() => relayFeed?.(data))
},
onData: (cb) => {
clientDataCallbacks.push(cb)
},
onClose: (cb) => {
closeCallbacks.push(cb)
}
}
const guestDispatcher = new RelayDispatcher((data: Buffer) => {
setImmediate(() => {
for (const cb of clientDataCallbacks) {
cb(data)
}
})
})
relayFeed = (data) => guestDispatcher.feed(data)
const mux = new SshChannelMultiplexer(transport)
return { transport, guestDispatcher, mux }
}
// Why skipIf: the fs bridge runs inside the Linux guest and is POSIX-only by
// design (posix.resolve). On a Windows dev host tmpdir() yields C:\ paths the
// bridge correctly refuses; Windows coverage comes from the live rig runs.
describe.skipIf(process.platform === 'win32')(
'createWslHookSftpAdapter over the guest fs bridge',
() => {
let home: string
let harness: GuestHarness
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'wsl-guest-home-'))
harness = createGuestHarness()
registerWslHookFsHandlers(harness.guestDispatcher, home)
})
afterEach(() => {
harness.mux.dispose()
harness.guestDispatcher.dispose()
rmSync(home, { recursive: true, force: true })
})
it('maps guest ENOENT onto ssh2 status code 2', async () => {
const adapter = createWslHookSftpAdapter(harness.mux)
const err = await new Promise<Error & { code?: number }>((resolve) => {
adapter.readFile(`${home}/missing.json`, 'utf8', ((e: Error) => resolve(e)) as never)
})
expect(err).toBeInstanceOf(Error)
expect(err.code).toBe(2)
})
it('round-trips write/read/stat/rename and rejects paths outside home', async () => {
const adapter = createWslHookSftpAdapter(harness.mux)
await new Promise<void>((resolve, reject) => {
adapter.writeFile(
`${home}/a.txt`,
'hello',
{ encoding: 'utf8', mode: 0o600 } as never,
((e: Error | null) => (e ? reject(e) : resolve())) as never
)
})
const content = await new Promise<string>((resolve, reject) => {
adapter.readFile(`${home}/a.txt`, 'utf8', ((e: Error | null, value: string) =>
e ? reject(e) : resolve(value)) as never)
})
expect(content).toBe('hello')
await new Promise<void>((resolve, reject) => {
adapter.ext_openssh_rename(`${home}/a.txt`, `${home}/b.txt`, ((e: Error | null) =>
e ? reject(e) : resolve()) as never)
})
expect(existsSync(`${home}/b.txt`)).toBe(true)
const outside = await new Promise<Error & { code?: number }>((resolve) => {
adapter.readFile('/etc/passwd', 'utf8', ((e: Error) => resolve(e)) as never)
})
expect(outside).toBeInstanceOf(Error)
})
it('runs the unchanged remote managed hook installers against a WSL guest home', async () => {
const adapter = createWslHookSftpAdapter(harness.mux)
const results = await installRemoteManagedAgentHooks(adapter, home)
expect(results.length).toBeGreaterThan(0)
expect(results.every((r) => r.state !== 'error')).toBe(true)
const claudeSettings = JSON.parse(
readFileSync(join(home, '.claude', 'settings.json'), 'utf8')
)
expect(claudeSettings.hooks).toBeTruthy()
const script = readFileSync(join(home, '.orca', 'agent-hooks', 'claude-hook.sh'), 'utf8')
expect(script).toContain('/hook/claude')
}, 20_000)
}
)
describe('WslHookRelayManager', () => {
// Why: a fixed POSIX guest home keeps this suite runnable on Windows dev
// hosts — installHooks is mocked here, so the fs bridge only ever serves
// the wslfs.home request and never touches the real filesystem.
const home = '/home/wsl-test-user'
let harnesses: GuestHarness[]
beforeEach(() => {
harnesses = []
})
afterEach(() => {
for (const h of harnesses) {
h.mux.dispose()
h.guestDispatcher.dispose()
}
})
function fakeChild(): ChildProcessWithoutNullStreams & { emitClose: () => void } {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
stdin: { write: () => boolean; end: () => void; on: () => void }
kill: () => void
emitClose: () => void
}
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.stdin = { write: () => true, end: () => {}, on: () => {} }
child.kill = () => {}
child.emitClose = () => child.emit('close', 0)
return child as unknown as ChildProcessWithoutNullStreams & { emitClose: () => void }
}
function guestTransport(): MultiplexerTransport {
const harness = createGuestHarness()
harnesses.push(harness)
registerWslHookFsHandlers(harness.guestDispatcher, home)
harness.guestDispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({
replayed: 0
}))
return harness.transport
}
function startupError(code: number | null, stderr = ''): Error {
return Object.assign(new Error(`exit ${code}`), {
startup: { kind: 'exit' as const, code, stderr }
})
}
function createManager(overrides: Partial<WslHookRelayManagerDeps>): {
manager: WslHookRelayManager
deps: WslHookRelayManagerDeps
} {
const deps: WslHookRelayManagerDeps = {
platform: () => 'win32',
remoteHooksEnabled: () => true,
hookCoordsEnv: () => ({
ORCA_AGENT_HOOK_PORT: '43117',
ORCA_AGENT_HOOK_TOKEN: 'tok',
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: '1'
}),
instanceKey: () => 'testinstance',
resolveBundle: () => ({ jsPath: '/fake/wsl-agent-hook-relay.js', version: '0.1.0+abc' }),
readBundle: () => Buffer.from('// bundle'),
listDistros: async () => ['Ubuntu'],
isDistroRunning: vi.fn(async () => true),
spawnRelay: vi.fn(() => fakeChild()),
runInstall: vi.fn(async () => ({ code: 0, stderr: '' })),
waitForSentinel: vi.fn(async () => guestTransport()),
ingest: vi.fn(),
installHooks: vi.fn(async () => []),
warn: vi.fn(),
transientRetryDelayMs: 1,
...overrides
}
return { manager: new WslHookRelayManager(deps), deps }
}
it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro('Ubuntu')
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
// Codex is the one agent whose home Orca redirects for WSL sessions.
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`
})
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe(
`${home}/.orca-wsl/agent-hooks/instance-testinstance/endpoint.env`
)
const guest = harnesses[0].guestDispatcher
guest.notify(AGENT_HOOK_NOTIFICATION_METHOD, {
paneKey: 'tab:leaf',
payload: { state: 'working' }
})
await vi.waitFor(() =>
expect(deps.ingest).toHaveBeenCalledWith(
expect.objectContaining({ paneKey: 'tab:leaf' }),
'wsl:Ubuntu'
)
)
guest.notify(AGENT_HOOK_NOTIFICATION_METHOD, { payload: { state: 'working' } })
await new Promise((resolve) => setTimeout(resolve, 20))
expect(deps.ingest).toHaveBeenCalledTimes(1)
manager.disposeAll()
})
it('resolves the default distro for null and dedupes it with the explicit name', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro(null)
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
manager.ensureForDistro('Ubuntu')
manager.ensureForDistro(null)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
expect(manager.getGuestEndpointFilePath(null)).toBe(
`${home}/.orca-wsl/agent-hooks/instance-testinstance/endpoint.env`
)
manager.disposeAll()
})
it('reinstalls once on a stale-version exit (42) and then connects', async () => {
const waitForSentinel = vi
.fn()
.mockRejectedValueOnce(startupError(42))
.mockImplementationOnce(async () => guestTransport())
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
expect(deps.runInstall).toHaveBeenCalledTimes(1)
expect(deps.spawnRelay).toHaveBeenCalledTimes(2)
manager.disposeAll()
})
it('gives up without installing when the guest has no node (43)', async () => {
const waitForSentinel = vi.fn().mockRejectedValue(startupError(43))
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() =>
expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('no node'))
)
expect(deps.runInstall).not.toHaveBeenCalled()
// Cooldown: an immediate re-ensure must not spawn again.
manager.ensureForDistro('Ubuntu')
await new Promise((resolve) => setTimeout(resolve, 20))
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
manager.disposeAll()
})
it('retries a bounded number of times on catastrophic wsl.exe failures', async () => {
const waitForSentinel = vi
.fn()
.mockRejectedValueOnce(startupError(1, 'Catastrophic failure (E_UNEXPECTED)'))
.mockRejectedValueOnce(startupError(1, 'Catastrophic failure (E_UNEXPECTED)'))
.mockImplementationOnce(async () => guestTransport())
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
expect(deps.spawnRelay).toHaveBeenCalledTimes(3)
expect(deps.runInstall).not.toHaveBeenCalled()
manager.disposeAll()
})
it('marks the distro failed when the relay exits and re-ensures only after cooldown', async () => {
const children: ReturnType<typeof fakeChild>[] = []
const spawnRelay = vi.fn(() => {
const child = fakeChild()
children.push(child)
return child
})
const { manager, deps } = createManager({ spawnRelay })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
children[0].emitClose()
await vi.waitFor(() =>
expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('exited'))
)
manager.ensureForDistro('Ubuntu')
await new Promise((resolve) => setTimeout(resolve, 20))
expect(spawnRelay).toHaveBeenCalledTimes(1)
manager.disposeAll()
})
it('is inert off-Windows and when remote hooks are disabled', async () => {
const offPlatform = createManager({ platform: () => 'darwin' })
offPlatform.manager.ensureForDistro('Ubuntu')
const disabled = createManager({ remoteHooksEnabled: () => false })
disabled.manager.ensureForDistro('Ubuntu')
await new Promise((resolve) => setTimeout(resolve, 20))
expect(offPlatform.deps.spawnRelay).not.toHaveBeenCalled()
expect(disabled.deps.spawnRelay).not.toHaveBeenCalled()
})
it('requires WSL fs-bridge home coordinates before exposing an endpoint path', () => {
const { manager } = createManager({})
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull()
expect(manager.getGuestEndpointFilePath(null)).toBeNull()
})
it('keeps a fresh state that replaced a failed one while its restart probe was in flight', async () => {
// Drain microtasks under fake timers (queueMicrotask is not a faked timer).
const flush = async (): Promise<void> => {
for (let i = 0; i < 25; i++) {
await Promise.resolve()
}
}
let resolveProbe: ((running: boolean) => void) | undefined
const isDistroRunning = vi.fn(() => new Promise<boolean>((resolve) => (resolveProbe = resolve)))
const spawnRelay = vi.fn(() => fakeChild())
// First launch fails outright; the replacement launch never reaches the
// sentinel, so its state stays 'starting' with no live mux to clean up.
const waitForSentinel = vi
.fn()
.mockRejectedValueOnce(new Error('relay died before sentinel'))
.mockReturnValueOnce(new Promise<never>(() => {}))
const { manager, deps } = createManager({ isDistroRunning, spawnRelay, waitForSentinel })
vi.useFakeTimers()
try {
manager.ensureForDistro('Ubuntu')
await flush()
expect(spawnRelay).toHaveBeenCalledTimes(1)
expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('relay died before sentinel'))
// Fire the restart timer; recovery blocks awaiting the distro-running probe.
await vi.advanceTimersByTimeAsync(FAILURE_COOLDOWN_BASE_MS + 300)
expect(isDistroRunning).toHaveBeenCalledTimes(1)
// A new WSL PTY spawn re-ensures past the elapsed cooldown, replacing the
// failed state in the map while the old state's probe is still pending.
manager.ensureForDistro('Ubuntu')
await flush()
expect(spawnRelay).toHaveBeenCalledTimes(2)
// Probe resolves 'not running' after the swap: the drop must be skipped so
// the replacement's live relay is not orphaned.
resolveProbe?.(false)
await flush()
expect(deps.warn).not.toHaveBeenCalledWith(expect.stringContaining('distro not running'))
// Fresh state survived: a further ensure dedupes instead of spawning again.
manager.ensureForDistro('Ubuntu')
await flush()
expect(spawnRelay).toHaveBeenCalledTimes(2)
} finally {
manager.disposeAll()
vi.useRealTimers()
}
})
})

View File

@ -0,0 +1,328 @@
// Host-side lifecycle manager for the guest-resident WSL agent-hook relay
// (STA-1515): one relay per distro per instance, ensured from every WSL PTY
// spawn, forwarding envelopes into ingestRemote and installing guest hooks.
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { installWslGuestHooks } from './wsl-hook-fs-adapter'
import { buildWslRelaySpawnEnv, launchWslRelayWithInstall } from './wsl-hook-relay-launch'
import {
defaultWslHookRelayDeps,
FAILURE_COOLDOWN_BASE_MS,
FAILURE_COOLDOWN_MAX_MS,
NO_NODE_COOLDOWN_MS,
REINSTALL_MIN_INTERVAL_MS,
REINSTALL_ONE_SHOT_DELAY_MS,
RUNNING_TEARDOWN_COOLDOWN_MS,
STABLE_UPTIME_MS,
type WslHookRelayManagerDeps
} from './wsl-hook-relay-deps'
import { wireWslRelayLink } from './wsl-hook-relay-link'
import { WslRelayRecovery } from './wsl-hook-relay-recovery'
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay'
import {
sanitizeWslHookInstanceKey,
WSL_HOOK_FS_METHODS,
wslHookRelayEndpointFilePath
} from '../../shared/wsl-hook-relay-contract'
type DistroState = {
/** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */
distro: string
phase: 'starting' | 'running' | 'failed'
child?: ChildProcessWithoutNullStreams
mux?: SshChannelMultiplexer
guestHome?: string
guestEndpointFilePath?: string
failures: number
cooldownUntil: number
connectedAt?: number
restartTimer?: ReturnType<typeof setTimeout>
reinstallTimer?: ReturnType<typeof setTimeout>
lastInstallAt?: number
}
function distroKey(distro: string): string {
return distro.trim().toLowerCase()
}
export class WslHookRelayManager {
private deps: WslHookRelayManagerDeps
private recovery: WslRelayRecovery
private states = new Map<string, DistroState>()
private defaultDistro: string | null = null
private disposed = false
private warnedBundleMissing = false
constructor(deps: Partial<WslHookRelayManagerDeps> = {}) {
this.deps = { ...defaultWslHookRelayDeps, ...deps }
this.recovery = new WslRelayRecovery({
isDistroRunning: (distro) => this.deps.isDistroRunning(distro),
warn: (message) => this.deps.warn(message),
isDisposed: () => this.disposed,
isCurrent: (state) => this.states.get(distroKey(state.distro)) === state,
restart: (distro) => this.ensureForDistro(distro),
dropState: (state) => {
// Why: identity-guarded — a fresh ensure() may own this key by now;
// deleting by key alone would orphan its live relay child.
const key = distroKey(state.distro)
if (this.states.get(key) === state) {
this.states.delete(key)
}
}
})
}
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
ensureForDistro(distro: string | null): void {
if (this.disposed || this.deps.platform() !== 'win32' || !this.deps.remoteHooksEnabled()) {
return
}
void this.ensureInternal(distro).catch((err) => {
this.deps.warn(
`[agent-hooks] WSL hook relay ensure failed: ${err instanceof Error ? err.message : String(err)}`
)
})
}
/** Guest endpoint file path once known; null before first connect
* (callers keep the /p-translated Windows endpoint path until then). */
getGuestEndpointFilePath(distro: string | null): string | null {
const name = distro ?? this.defaultDistro
if (!name) {
return null
}
return this.states.get(distroKey(name))?.guestEndpointFilePath ?? null
}
disposeAll(): void {
this.disposed = true
for (const state of this.states.values()) {
this.recovery.clearTimers(state)
state.mux?.dispose()
state.child?.kill()
}
this.states.clear()
}
private async ensureInternal(requestedDistro: string | null): Promise<void> {
const distro = requestedDistro ?? (await this.resolveDefaultDistro())
if (!distro || this.disposed) {
return
}
const key = distroKey(distro)
const existing = this.states.get(key)
if (existing) {
if (existing.phase === 'running') {
void this.maybeReinstallHooks(existing)
return
}
if (existing.phase !== 'failed' || Date.now() < existing.cooldownUntil) {
return
}
}
const coords = this.deps.hookCoordsEnv()
const port = Number(coords.ORCA_AGENT_HOOK_PORT ?? '')
if (!Number.isInteger(port) || port <= 0 || !coords.ORCA_AGENT_HOOK_TOKEN) {
return
}
const bundle = this.deps.resolveBundle()
if (!bundle) {
if (!this.warnedBundleMissing) {
this.warnedBundleMissing = true
this.deps.warn('[agent-hooks] WSL hook relay bundle not found; run build:relay')
}
return
}
// Why: restart-stable instance identity keeps the guest endpoint file at
// ONE path across restarts so daemon-surviving agents re-coordinate.
const instanceKey =
sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) ?? `port${port}`
if (existing) {
this.recovery.clearTimers(existing)
}
const state: DistroState = {
distro,
phase: 'starting',
failures: existing?.failures ?? 0,
cooldownUntil: 0
}
this.states.set(key, state)
const env = buildWslRelaySpawnEnv(coords, bundle.version, instanceKey)
try {
await launchWslRelayWithInstall({
distro: state.distro,
env,
bundleJsPath: bundle.jsPath,
version: bundle.version,
io: this.deps,
isDisposed: () => this.disposed,
onChild: (child) => {
state.child = child
},
onNoNode: () =>
this.markFailed(
state,
`no node >= 18 found in distro '${state.distro}'; agent hooks stay degraded there`,
{ cooldownBaseMs: NO_NODE_COOLDOWN_MS }
),
onFailure: (message) =>
this.markFailed(state, message, { cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS }),
connect: (transport, child) => this.connect(state, transport, child, instanceKey)
})
} catch (err) {
// Why: teardown may have already recorded this failure; don't double-
// count. A request-level error can leave a live child — never leak it.
state.child?.kill()
state.mux?.dispose()
if (state.phase !== 'failed') {
this.markFailed(state, err instanceof Error ? err.message : String(err), {
cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS
})
}
}
}
private async connect(
state: DistroState,
transport: MultiplexerTransport,
child: ChildProcessWithoutNullStreams,
instanceKey: string
): Promise<void> {
const mux = new SshChannelMultiplexer(transport)
state.mux = mux
wireWslRelayLink({
mux,
child,
distro: state.distro,
ingest: this.deps.ingest,
warn: this.deps.warn,
onDead: (reason) => {
if (this.disposed || state.mux !== mux) {
return
}
state.mux = undefined
const wasRunning = state.phase === 'running'
// Why: only a stable run forgives past failures — a connect-then-die
// loop must escalate, not retry every 10s.
if (
wasRunning &&
state.connectedAt !== undefined &&
Date.now() - state.connectedAt >= STABLE_UPTIME_MS
) {
state.failures = 0
}
this.markFailed(state, `relay link for '${state.distro}' ${reason}; scheduling restart`, {
cooldownBaseMs: wasRunning ? RUNNING_TEARDOWN_COOLDOWN_MS : FAILURE_COOLDOWN_BASE_MS
})
}
})
const homeResult = (await mux.request(WSL_HOOK_FS_METHODS.home)) as {
ok?: boolean
home?: string
portFallback?: boolean
boundPort?: number
}
if (homeResult?.ok !== true || typeof homeResult.home !== 'string') {
throw new Error(`relay for '${state.distro}' returned no home dir`)
}
if (homeResult.portFallback === true) {
this.deps.warn(
`[agent-hooks] WSL hook relay (${state.distro}): preferred port occupied in guest; bound ${homeResult.boundPort ?? 'unknown'} (endpoint-file re-coordination)`
)
}
state.guestHome = homeResult.home
state.guestEndpointFilePath = wslHookRelayEndpointFilePath(homeResult.home, instanceKey)
await this.runInstallers(state, mux, homeResult.home)
if (state.phase === 'failed' || state.mux !== mux) {
// Child died while installing — already recorded; don't revive.
return
}
state.phase = 'running'
state.connectedAt = Date.now()
// Why: one-shot catch-up so a single-spawn session (no later ensure)
// still writes Codex's deferred trust after the launch path seeds config.toml.
this.recovery.scheduleOneShotReinstall(state, REINSTALL_ONE_SHOT_DELAY_MS, () => {
void this.maybeReinstallHooks(state)
})
void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch(() => {
// Fresh relays have nothing to replay; tolerate.
})
}
private async runInstallers(
state: DistroState,
mux: SshChannelMultiplexer,
guestHome: string
): Promise<void> {
state.lastInstallAt = Date.now()
await installWslGuestHooks({
mux,
guestHome,
distro: state.distro,
installHooks: this.deps.installHooks,
warn: this.deps.warn
})
}
private async maybeReinstallHooks(state: DistroState): Promise<void> {
const mux = state.mux
const guestHome = state.guestHome
if (
!mux ||
!guestHome ||
mux.isDisposed() ||
Date.now() - (state.lastInstallAt ?? 0) < REINSTALL_MIN_INTERVAL_MS
) {
return
}
try {
await this.runInstallers(state, mux, guestHome)
} catch (err) {
this.deps.warn(
`[agent-hooks] WSL hook reinstall for '${state.distro}' failed: ${err instanceof Error ? err.message : String(err)}`
)
}
}
/** Records + breadcrumbs the failure and always arms the restart timer
* one failed relaunch must not end self-recovery; the timer's
* distro-running probe keeps this from booting stopped distros. */
private markFailed(
state: DistroState,
message: string,
options: { cooldownBaseMs: number }
): void {
state.phase = 'failed'
state.failures++
state.child = undefined
state.mux = undefined
if (state.reinstallTimer) {
clearTimeout(state.reinstallTimer)
state.reinstallTimer = undefined
}
state.cooldownUntil =
Date.now() + Math.min(options.cooldownBaseMs * state.failures, FAILURE_COOLDOWN_MAX_MS)
this.deps.warn(`[agent-hooks] WSL hook relay (${state.distro}): ${message}`)
this.recovery.scheduleRestart(state)
}
private async resolveDefaultDistro(): Promise<string | null> {
if (this.defaultDistro) {
return this.defaultDistro
}
try {
const distros = await this.deps.listDistros()
this.defaultDistro = distros[0] ?? null
} catch {
this.defaultDistro = null
}
return this.defaultDistro
}
}
export const wslHookRelayManager = new WslHookRelayManager()

View File

@ -0,0 +1,155 @@
// Restart-timer policy: recovery must re-ensure a running distro, must NOT
// boot a stopped one (wsl -d starts stopped distros), and must respect state
// currency and disposal.
import { describe, expect, it, vi } from 'vitest'
import { WslRelayRecovery, type WslRelayRecoveryState } from './wsl-hook-relay-recovery'
function makeState(): WslRelayRecoveryState {
return { distro: 'Ubuntu', cooldownUntil: Date.now() - 1_000 }
}
function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise<void> {
return vi.waitFor(() => expect(condition()).toBe(true), { timeout: timeoutMs })
}
describe('WslRelayRecovery', () => {
it('re-ensures the distro when the restart timer fires and the distro is running', async () => {
const restart = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: async () => true,
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => true,
restart,
dropState: vi.fn()
})
const state = makeState()
recovery.scheduleRestart(state)
await waitFor(() => restart.mock.calls.length === 1)
expect(restart).toHaveBeenCalledWith('Ubuntu')
})
it('drops the state instead of booting a stopped distro', async () => {
const restart = vi.fn()
const dropState = vi.fn()
const warn = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: async () => false,
warn,
isDisposed: () => false,
isCurrent: () => true,
restart,
dropState
})
const state = makeState()
recovery.scheduleRestart(state)
await waitFor(() => dropState.mock.calls.length === 1)
expect(restart).not.toHaveBeenCalled()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('distro not running'))
})
it('does nothing when the state was replaced or the manager is disposed', async () => {
const restart = vi.fn()
const probe = vi.fn(async () => true)
const recovery = new WslRelayRecovery({
isDistroRunning: probe,
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => false,
restart,
dropState: vi.fn()
})
const state = makeState()
recovery.scheduleRestart(state)
await new Promise((resolve) => setTimeout(resolve, 500))
expect(probe).not.toHaveBeenCalled()
expect(restart).not.toHaveBeenCalled()
})
it('does not drop or restart when the state was replaced mid-probe (probe false)', async () => {
let current = true
let resolveProbe: ((running: boolean) => void) | undefined
const probe = vi.fn(() => new Promise<boolean>((resolve) => (resolveProbe = resolve)))
const restart = vi.fn()
const dropState = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: probe,
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => current,
restart,
dropState
})
const state = makeState()
recovery.scheduleRestart(state)
await waitFor(() => probe.mock.calls.length === 1)
// A fresh ensure() replaces this state while the probe is in flight.
current = false
resolveProbe?.(false)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(dropState).not.toHaveBeenCalled()
expect(restart).not.toHaveBeenCalled()
})
it('does not restart when the state was replaced mid-probe (probe true)', async () => {
let current = true
let resolveProbe: ((running: boolean) => void) | undefined
const probe = vi.fn(() => new Promise<boolean>((resolve) => (resolveProbe = resolve)))
const restart = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: probe,
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => current,
restart,
dropState: vi.fn()
})
const state = makeState()
recovery.scheduleRestart(state)
await waitFor(() => probe.mock.calls.length === 1)
current = false
resolveProbe?.(true)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(restart).not.toHaveBeenCalled()
})
it('scheduleOneShotReinstall is a no-op once disposed', async () => {
const run = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: async () => true,
warn: vi.fn(),
isDisposed: () => true,
isCurrent: () => true,
restart: vi.fn(),
dropState: vi.fn()
})
const state = makeState()
recovery.scheduleOneShotReinstall(state, 10, run)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(run).not.toHaveBeenCalled()
expect(state.reinstallTimer).toBeUndefined()
})
it('clearTimers cancels both pending timers', async () => {
const restart = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: async () => true,
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => true,
restart,
dropState: vi.fn()
})
const state = makeState()
const reinstall = vi.fn()
recovery.scheduleRestart(state)
recovery.scheduleOneShotReinstall(state, 100, reinstall)
recovery.clearTimers(state)
await new Promise((resolve) => setTimeout(resolve, 500))
expect(restart).not.toHaveBeenCalled()
expect(reinstall).not.toHaveBeenCalled()
expect(state.restartTimer).toBeUndefined()
expect(state.reinstallTimer).toBeUndefined()
})
})

View File

@ -0,0 +1,81 @@
// Restart/reinstall timer policy for WSL hook relay states. Owns the two
// self-recovery timers so the manager's state machine stays declarative:
// WHEN to retry lives here, WHAT retrying means stays in the manager.
export type WslRelayRecoveryState = {
distro: string
cooldownUntil: number
restartTimer?: ReturnType<typeof setTimeout>
reinstallTimer?: ReturnType<typeof setTimeout>
}
export type WslRelayRecoveryIo = {
isDistroRunning: (distro: string) => Promise<boolean>
warn: (message: string) => void
isDisposed: () => boolean
/** True while this state object is still the one in the manager's map. */
isCurrent: (state: WslRelayRecoveryState) => boolean
restart: (distro: string) => void
dropState: (state: WslRelayRecoveryState) => void
}
export class WslRelayRecovery {
constructor(private io: WslRelayRecoveryIo) {}
/** Arms the restart timer for the state's cooldown. The probe gate matters:
* `wsl -d` BOOTS a stopped distro, so recovery must never resurrect a VM
* the user shut down a stopped distro has no live agents anyway. */
scheduleRestart(state: WslRelayRecoveryState): void {
if (this.io.isDisposed() || state.restartTimer) {
return
}
const delayMs = Math.max(state.cooldownUntil - Date.now(), 0) + 250
state.restartTimer = setTimeout(() => {
state.restartTimer = undefined
void this.restartIfDistroRunning(state)
}, delayMs)
state.restartTimer.unref?.()
}
scheduleOneShotReinstall(state: WslRelayRecoveryState, delayMs: number, run: () => void): void {
if (this.io.isDisposed()) {
return
}
state.reinstallTimer = setTimeout(() => {
state.reinstallTimer = undefined
run()
}, delayMs)
state.reinstallTimer.unref?.()
}
clearTimers(state: WslRelayRecoveryState): void {
if (state.restartTimer) {
clearTimeout(state.restartTimer)
state.restartTimer = undefined
}
if (state.reinstallTimer) {
clearTimeout(state.reinstallTimer)
state.reinstallTimer = undefined
}
}
private async restartIfDistroRunning(state: WslRelayRecoveryState): Promise<void> {
if (this.io.isDisposed() || !this.io.isCurrent(state)) {
return
}
const running = await this.io.isDistroRunning(state.distro)
// Why: a fresh ensure() may have replaced this state during the probe
// await — dropping/restarting here would then act on the replacement,
// orphaning its live relay child outside the manager's map.
if (this.io.isDisposed() || !this.io.isCurrent(state)) {
return
}
if (!running) {
this.io.warn(
`[agent-hooks] WSL hook relay (${state.distro}): distro not running (or probe failed); restart skipped (next WSL terminal re-ensures)`
)
this.io.dropState(state)
return
}
this.io.restart(state.distro)
}
}

View File

@ -0,0 +1,152 @@
// Sentinel wait for the WSL relay: consuming stdout until the READY sentinel,
// handing trailing/subsequent bytes to the mux in wire order via the microtask
// flush, and failing (kill + reject) on overflow, close, timeout, and NUL noise.
import { EventEmitter } from 'node:events'
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from '../ssh/relay-protocol'
import {
MAX_STARTUP_BUFFER_BYTES,
waitForWslRelaySentinel,
type WslRelayStartupFailure
} from './wsl-hook-relay-sentinel'
type FakeChild = ChildProcessWithoutNullStreams & { kill: ReturnType<typeof vi.fn> }
function fakeChild(): FakeChild {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
stdin: { write: ReturnType<typeof vi.fn> }
kill: ReturnType<typeof vi.fn>
}
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.stdin = { write: vi.fn(() => true) }
child.kill = vi.fn()
return child as unknown as FakeChild
}
function emitStdout(child: ChildProcessWithoutNullStreams, data: string | Buffer): void {
child.stdout.emit('data', Buffer.isBuffer(data) ? data : Buffer.from(data))
}
function emitStderr(child: ChildProcessWithoutNullStreams, data: Buffer): void {
child.stderr.emit('data', data)
}
function catchStartup(
promise: Promise<unknown>
): Promise<Error & { startup?: WslRelayStartupFailure }> {
return promise.then(
() => {
throw new Error('expected the sentinel wait to reject')
},
(err: Error & { startup?: WslRelayStartupFailure }) => err
)
}
afterEach(() => {
vi.useRealTimers()
})
describe('waitForWslRelaySentinel', () => {
it('resolves when the sentinel is split across two chunks', async () => {
const child = fakeChild()
const promise = waitForWslRelaySentinel(child)
const sentinel = Buffer.from(RELAY_SENTINEL)
const mid = Math.floor(sentinel.length / 2)
emitStdout(child, sentinel.subarray(0, mid))
emitStdout(child, sentinel.subarray(mid))
const transport = await promise
expect(typeof transport.write).toBe('function')
expect(typeof transport.onData).toBe('function')
})
it('resolves past leading garbage and hands trailing bytes to onData', async () => {
const child = fakeChild()
const promise = waitForWslRelaySentinel(child)
emitStdout(
child,
Buffer.concat([Buffer.from('junk noise '), Buffer.from(RELAY_SENTINEL), Buffer.from('TRAIL')])
)
const transport = await promise
const received: string[] = []
transport.onData((d) => received.push(d.toString('utf8')))
await Promise.resolve()
expect(received).toEqual(['TRAIL'])
})
it('delivers a pending trailing chunk before a later direct chunk, in wire order', async () => {
const child = fakeChild()
const promise = waitForWslRelaySentinel(child)
emitStdout(child, Buffer.concat([Buffer.from(RELAY_SENTINEL), Buffer.from('FIRST')]))
const transport = await promise
const received: string[] = []
transport.onData((d) => received.push(d.toString('utf8')))
// A real stdout 'data' event is a macrotask; the queued pending flush is a
// microtask and must land ahead of any subsequent direct chunk.
await new Promise<void>((resolve) => {
setImmediate(() => {
emitStdout(child, 'SECOND')
resolve()
})
})
expect(received).toEqual(['FIRST', 'SECOND'])
})
it('defers the pending flush to a microtask so a caller can finish wiring', async () => {
const child = fakeChild()
const promise = waitForWslRelaySentinel(child)
emitStdout(child, Buffer.concat([Buffer.from(RELAY_SENTINEL), Buffer.from('DEFER')]))
const transport = await promise
const received: string[] = []
transport.onData((d) => received.push(d.toString('utf8')))
// Nothing dispatched synchronously at registration — a second handler could
// still be added this tick before the first envelope flushes.
expect(received).toEqual([])
await Promise.resolve()
expect(received).toEqual(['DEFER'])
})
it('kills the child and rejects when startup output exceeds 64 KiB before the sentinel', async () => {
const child = fakeChild()
const settled = catchStartup(waitForWslRelaySentinel(child))
emitStdout(child, Buffer.alloc(MAX_STARTUP_BUFFER_BYTES + 1, 0x41))
const err = await settled
expect(err.message).toMatch(/64 KiB/)
expect(child.kill).toHaveBeenCalled()
})
it('rejects with an exit failure when the child closes before the sentinel', async () => {
const child = fakeChild()
const settled = catchStartup(waitForWslRelaySentinel(child))
child.emit('close', 7)
const err = await settled
expect(err.startup).toEqual({ kind: 'exit', code: 7, stderr: '' })
})
it('kills the child and rejects with a timeout after the sentinel deadline', async () => {
vi.useFakeTimers()
const child = fakeChild()
const settled = catchStartup(waitForWslRelaySentinel(child))
await vi.advanceTimersByTimeAsync(RELAY_SENTINEL_TIMEOUT_MS + 1)
const err = await settled
expect(child.kill).toHaveBeenCalled()
expect(err.startup?.kind).toBe('timeout')
})
it('strips NUL bytes from the stderr failure detail', async () => {
const child = fakeChild()
const settled = catchStartup(waitForWslRelaySentinel(child))
// wsl.exe without WSL_UTF8 emits UTF-16LE — "E_FAIL" as NUL-interleaved ASCII.
const nulLaden = Buffer.from('E_FAIL'.split('').flatMap((c) => [c.charCodeAt(0), 0]))
emitStderr(child, nulLaden)
child.emit('close', 1)
const err = await settled
expect(err.startup?.stderr).toBe('E_FAIL')
expect(err.message).toContain('E_FAIL')
expect(err.message).not.toContain(String.fromCharCode(0))
})
})

View File

@ -0,0 +1,159 @@
// Sentinel wait for the WSL agent-hook relay: consume the guest child's
// stdout until the READY sentinel, then hand the remaining stdio over as a
// MultiplexerTransport. WSL twin of the SSH deploy's waitForSentinel, over a
// ChildProcess instead of a ClientChannel.
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from '../ssh/relay-protocol'
import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
export const MAX_STARTUP_BUFFER_BYTES = 64 * 1024
// Why: without WSL_UTF8, wsl.exe's own messages arrive UTF-16LE; NUL bytes
// in breadcrumbs and the catastrophic-failure matcher must not depend on the
// env var having taken effect (older wsl.exe ignores it).
export function decodeWslText(value: string): string {
return value.split(String.fromCharCode(0)).join('')
}
export type WslRelayStartupFailure = {
kind: 'exit' | 'timeout'
code: number | null
stderr: string
}
/** Wait for the relay's ready sentinel on the child's stdout, then hand the
* remaining stdio over as a MultiplexerTransport. WSL twin of the SSH
* deploy's waitForSentinel, over a ChildProcess instead of a ClientChannel. */
export function waitForWslRelaySentinel(
child: ChildProcessWithoutNullStreams
): Promise<MultiplexerTransport> {
return new Promise((resolve, reject) => {
let settled = false
let sentinelSeen = false
let stdoutBuffer: Buffer = Buffer.alloc(0)
let stderrOutput = ''
let exitCode: number | null = null
const sentinel = Buffer.from(RELAY_SENTINEL, 'utf8')
const dataCallbacks: ((data: Buffer) => void)[] = []
const closeCallbacks: (() => void)[] = []
// Why: post-sentinel chunks queue until the mux registers onData, then
// flush as a microtask — after the caller's synchronous wiring (the mux
// constructor registers onData before the manager can add notification
// handlers, so a synchronous flush could dispatch an early envelope to
// zero handlers) yet before any subsequent stdout IO event, so the frame
// decoder never sees chunks out of order. A setImmediate handoff would
// NOT preserve that: it is a macrotask the next 'data' event can beat.
const pendingChunks: Buffer[] = []
let closedNotified = false
const fail = (failure: WslRelayStartupFailure): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
reject(Object.assign(new Error(formatStartupFailure(failure)), { startup: failure }))
}
const timeout = setTimeout(() => {
child.kill()
fail({ kind: 'timeout', code: null, stderr: stderrOutput })
}, RELAY_SENTINEL_TIMEOUT_MS)
const notifyClosed = (): void => {
if (!closedNotified) {
closedNotified = true
for (const cb of closeCallbacks) {
cb()
}
}
}
const dispatch = (chunk: Buffer): void => {
if (dataCallbacks.length === 0) {
pendingChunks.push(chunk)
return
}
for (const cb of dataCallbacks) {
cb(chunk)
}
}
child.stderr.on('data', (d: Buffer) => {
stderrOutput = (stderrOutput + decodeWslText(d.toString('utf8'))).slice(
-MAX_STARTUP_BUFFER_BYTES
)
})
child.on('error', (err) =>
fail({ kind: 'exit', code: null, stderr: `${stderrOutput}\n${err.message}` })
)
child.on('exit', (code) => {
exitCode = code
})
child.on('close', (code) => {
if (sentinelSeen) {
notifyClosed()
return
}
fail({ kind: 'exit', code: code ?? exitCode, stderr: stderrOutput })
})
child.stdout.on('data', (chunk: Buffer) => {
if (sentinelSeen) {
dispatch(chunk)
return
}
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
const idx = stdoutBuffer.indexOf(sentinel)
if (idx === -1) {
// Why: pre-sentinel stdout is untrusted startup noise; cap it so a
// broken guest cannot grow memory until the timeout fires.
if (stdoutBuffer.length > MAX_STARTUP_BUFFER_BYTES) {
child.kill()
fail({ kind: 'exit', code: null, stderr: 'startup output exceeded 64 KiB' })
}
return
}
sentinelSeen = true
settled = true
clearTimeout(timeout)
const trailing = stdoutBuffer.subarray(idx + sentinel.length)
if (trailing.length > 0) {
pendingChunks.push(trailing)
}
const transport: MultiplexerTransport = {
write: (data) => {
try {
child.stdin.write(data)
} catch {
// Channel already closing — mux close handling takes over.
}
},
onData: (cb) => {
dataCallbacks.push(cb)
if (dataCallbacks.length === 1 && pendingChunks.length > 0) {
queueMicrotask(() => {
for (const pending of pendingChunks.splice(0)) {
for (const dataCb of dataCallbacks) {
dataCb(pending)
}
}
})
}
},
onClose: (cb) => closeCallbacks.push(cb),
close: () => child.kill()
}
resolve(transport)
})
})
}
function formatStartupFailure(failure: WslRelayStartupFailure): string {
const detail = failure.stderr.trim()
if (failure.kind === 'timeout') {
return `WSL hook relay did not become ready within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s${detail ? `: ${detail}` : ''}`
}
return `WSL hook relay exited (code ${failure.code ?? 'unknown'})${detail ? `: ${detail}` : ''}`
}

View File

@ -33,6 +33,7 @@ import {
import { app } from 'electron'
import type { CodexManagedAccount } from '../../shared/types'
import type { Store } from '../persistence'
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
import { writeFileAtomically } from './fs-utils'
import {
getOrcaManagedCodexHomePath,
@ -657,9 +658,7 @@ export class CodexRuntimeHomeService {
private getWslRuntimeHomePath(distro: string): string | null {
const home = getWslHome(distro)
return home
? this.joinWslPath(home, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
: null
return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null
}
private safeReadBackActiveWslAccountBeforeRestart(

View File

@ -1184,9 +1184,25 @@ export class CodexHookService {
return this.getStatus()
}
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.codex/hooks.json`
const remoteTomlPath = `${remoteHome.replace(/\/$/, '')}/.codex/config.toml`
async installRemote(
sftp: SFTPWrapper,
remoteHome: string,
options?: {
/** Explicit CODEX_HOME dir (flat layout: hooks.json/config.toml at its
* root). WSL sessions read Orca's managed runtime home, not ~/.codex
* installing to the default location leaves those sessions hookless. */
codexHomeDir?: string
/** Skip the trust write when config.toml doesn't exist yet. The WSL
* runtime home's config.toml is seeded only-if-absent by the launch
* path; creating it here first would silently cancel that seed. A
* later (idempotent) reinstall upserts trust once the seed lands. */
deferTrustUntilConfigToml?: boolean
}
): Promise<AgentHookInstallStatus> {
const codexHomeBase =
options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex`
const remoteConfigPath = `${codexHomeBase}/hooks.json`
const remoteTomlPath = `${codexHomeBase}/config.toml`
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
@ -1245,7 +1261,17 @@ export class CodexHookService {
// Preserve non-Orca top-level metadata while replacing the hooks tree.
await writeHooksJsonRemote(sftp, remoteConfigPath, { ...config, hooks: nextHooks })
try {
const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? ''
const existingTomlRaw = await readTextFileRemote(sftp, remoteTomlPath)
if (existingTomlRaw === null && options?.deferTrustUntilConfigToml === true) {
return {
agent: 'codex',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: 'Trust entries deferred until config.toml is seeded by the launch path'
}
}
const existingToml = existingTomlRaw ?? ''
const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries)
if (updatedToml !== existingToml) {
await writeTextFileRemoteAtomic(sftp, remoteTomlPath, updatedToml)

View File

@ -132,6 +132,7 @@ import {
} from './claude-accounts/live-pty-gate'
import { StarNagService } from './star-nag/service'
import { agentHookServer } from './agent-hooks/server'
import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager'
import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename'
import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename'
import { moveWorktree } from './git/worktree'
@ -2257,6 +2258,9 @@ app.on('will-quit', (e) => {
automations?.stop()
setUnreadDockBadgeCount(0)
agentHookServer.stop()
// Why: cancels relay restart/reinstall timers and kills wsl.exe children
// deterministically instead of relying on stdio-pipe teardown.
wslHookRelayManager.disposeAll()
stats?.flush()
// Why: agent-browser daemon processes would otherwise linger after Orca quits,
// holding ports and leaving stale session state on disk.

View File

@ -31,6 +31,7 @@ import {
getFirstCommandToken
} from '../../shared/command-token-scanner'
import { agentHookServer } from '../agent-hooks/server'
import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { piTitlebarExtensionService } from '../pi/titlebar-extension-service'
import { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi-agent-kind'
@ -508,6 +509,9 @@ export type BuildPtyHostEnvOptions = {
launchCommand?: string
shellPath?: string
isWsl?: boolean
/** Distro for WSL spawns (null = Windows default distro). Drives the WSL
* hook relay ensure + guest endpoint repoint; only read when isWsl. */
wslDistro?: string | null
agentStatusHooksEnabled: boolean
networkProxySettings?: NetworkProxySettings
}
@ -865,6 +869,19 @@ export function buildPtyHostEnv(
}
if (opts.agentStatusHooksEnabled) {
Object.assign(baseEnv, agentHookServer.buildPtyEnv())
if (opts.isWsl === true) {
// Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace. Ensure
// the guest-resident relay for this distro (covers fresh spawns and
// post-restart reattach re-spawns), and once the relay has reported the
// guest home, point restart re-coordination at the relay-written
// guest-side endpoint file instead of the /p-translated Windows one.
const distro = opts.wslDistro ?? null
wslHookRelayManager.ensureForDistro(distro)
const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro)
if (guestEndpoint) {
baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint
}
}
}
// Why: PI_CODING_AGENT_DIR owns Pi's / OMP's full config/session root. Keep
@ -1327,6 +1344,7 @@ export function registerPtyHandlers(
launchCommand: ctx?.command,
shellPath: ctx?.shellPath,
isWsl: ctx?.isWsl,
wslDistro: ctx?.wslDistro ?? null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
@ -2187,6 +2205,7 @@ export function registerPtyHandlers(
launchCommand: args.command,
shellPath: daemonShellOverride ?? process.env.COMSPEC,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd),
wslDistro: codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
@ -2960,6 +2979,8 @@ export function registerPtyHandlers(
launchCommand: args.command,
shellPath: effectiveShellOverride ?? process.env.COMSPEC,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd),
wslDistro:
codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})

View File

@ -1,3 +1,19 @@
/** Guest-relative layout of Orca's managed WSL CODEX_HOME. Must stay in sync
* with getWslRuntimeHomePath (codex-accounts/runtime-home-service.ts), which
* builds the UNC twin of this path. */
export const WSL_CODEX_RUNTIME_HOME_SEGMENTS = [
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
] as const
export function wslCodexRuntimeHomeForGuestHome(guestHome: string): string {
const home = guestHome.endsWith('/') ? guestHome.slice(0, -1) : guestHome
return `${home}/${WSL_CODEX_RUNTIME_HOME_SEGMENTS.join('/')}`
}
export function isHostCodexHomeForWsl(value: string | undefined): boolean {
const trimmed = value?.trim()
if (!trimmed) {

View File

@ -47,4 +47,25 @@ describe('addOrcaWslInteropEnv', () => {
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u')
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u')
})
it('path-translates a Windows hook endpoint but passes a guest-side one untouched', () => {
const windowsEnv: Record<string, string> = {
ORCA_AGENT_HOOK_ENDPOINT: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\agent-hooks\\endpoint.cmd'
}
addOrcaWslInteropEnv(windowsEnv)
expect(windowsEnv.WSLENV).toContain('ORCA_AGENT_HOOK_ENDPOINT/p')
const guestEnv: Record<string, string> = {
ORCA_AGENT_HOOK_ENDPOINT: '/home/jin/.orca-wsl/agent-hooks/port-4567/endpoint.env'
}
addOrcaWslInteropEnv(guestEnv)
expect(guestEnv.WSLENV).toContain('ORCA_AGENT_HOOK_ENDPOINT/u')
expect(guestEnv.WSLENV).not.toContain('ORCA_AGENT_HOOK_ENDPOINT/p')
})
it('marks the WSL hook relay version for import on relay spawn envs', () => {
const env: Record<string, string> = { ORCA_WSL_HOOK_RELAY_VERSION: '0.1.0+abc' }
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toBe('ORCA_WSL_HOOK_RELAY_VERSION/u')
})
})

View File

@ -16,6 +16,10 @@ function upsertWslenvEntry(entries: string[], entry: string): void {
export function addOrcaWslInteropEnv(env: Record<string, string>): void {
const entries = parseWslenvEntries(env.WSLENV)
// Why: the endpoint is a Windows path (/p-translated so the guest reads it
// via /mnt/c) until the WSL hook relay reports the guest home — then it is
// already a guest-side POSIX path and must cross untranslated.
const endpointFlag = env.ORCA_AGENT_HOOK_ENDPOINT?.startsWith('/') ? 'u' : 'p'
// Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start.
const passthroughEntries = [
'ORCA_TERMINAL_HANDLE/u',
@ -28,7 +32,9 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
'ORCA_AGENT_HOOK_TOKEN/u',
'ORCA_AGENT_HOOK_ENV/u',
'ORCA_AGENT_HOOK_VERSION/u',
'ORCA_AGENT_HOOK_ENDPOINT/p',
`ORCA_AGENT_HOOK_ENDPOINT/${endpointFlag}`,
'ORCA_WSL_HOOK_RELAY_VERSION/u',
'ORCA_WSL_HOOK_INSTANCE/u',
'ORCA_OMP_SOURCE_AGENT_DIR/p',
'ORCA_OMP_STATUS_EXTENSION/p'
]

View File

@ -331,4 +331,51 @@ describe('RelayAgentHookServer', () => {
vi.unstubAllEnvs()
}
})
it('caps the replay cache at 256 panes, evicting the least-recently-updated', async () => {
// Mirrors the server's private MAX_CACHED_PANES. The WSL relay never gets a
// per-pane teardown signal, so the cache is recency-capped instead.
const CAP = 256
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
const { port, token } = server.getCoordinates()
const paneKeyFor = (i: number): string => makePaneKey(`tab-${i}`, LEAF_ID)
const postPane = (paneKey: string): Promise<Response> =>
fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey,
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'p' }
})
})
// Fill the cache to exactly the cap in insertion order 0..CAP-1. Sequential
// awaits pin Map order = update recency, which the eviction relies on.
for (let i = 0; i < CAP; i++) {
await postPane(paneKeyFor(i))
}
// Refresh the OLDEST pane just before overflow, then push one more pane.
// Recency (not insertion) order must now evict pane 1, sparing pane 0.
await postPane(paneKeyFor(0))
await postPane(paneKeyFor(CAP))
forward.mockClear()
const replayed = server.replayCachedPayloadsForPanes()
expect(replayed).toBe(CAP)
const cachedPaneKeys = new Set(forward.mock.calls.map((call) => call[0].paneKey))
expect(cachedPaneKeys.size).toBe(CAP)
expect(cachedPaneKeys.has(paneKeyFor(0))).toBe(true)
expect(cachedPaneKeys.has(paneKeyFor(CAP))).toBe(true)
expect(cachedPaneKeys.has(paneKeyFor(1))).toBe(false)
} finally {
server.stop()
}
}, 30_000)
})

View File

@ -54,6 +54,12 @@ const ASSISTANT_MESSAGE_RETRY_MS = 50
// '1'/'999'); anything longer is treated as absent.
const MAX_HOOK_META_LEN = 64
// Why: the WSL relay has no per-pane teardown signal (PTYs live on the
// Windows host, so nothing calls clearPaneState), and the replay cache would
// otherwise grow for the relay's lifetime. Recency-cap it; backstop for the
// SSH relay too.
const MAX_CACHED_PANES = 256
function defaultEndpointDir(): string {
return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR)
}
@ -84,6 +90,15 @@ export type RelayHookServerOptions = {
/** Env tag forwarded into hook payloads. Defaults to "remote", a relay
* location marker that main excludes from dev-vs-prod mismatch warnings. */
env?: string
/** Fixed auth token. The WSL relay passes the host-issued token that
* already crossed into guest env via WSLENV, so unmodified hook clients
* authenticate without any re-coordination. Defaults to a fresh UUID. */
token?: string
/** Preferred bind port. The WSL relay passes the Windows listener's port
* free inside the guest under NAT, so env-sourced client coords stay
* truthful. Occupied (e.g. mirrored networking) fall back to :0 and rely
* on the endpoint file for re-coordination. Defaults to :0. */
preferredPort?: number
/** Called once per parsed payload. The relay wires this to
* `dispatcher.notify('agent.hook', envelope)`. */
forward: RelayHookForward
@ -115,11 +130,16 @@ export class RelayAgentHookServer {
> = new Map()
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
private forward: RelayHookForward
private fixedToken: string | undefined
private preferredPort: number
private portFallbackApplied = false
constructor(options: RelayHookServerOptions) {
this.env = options.env ?? REMOTE_AGENT_HOOK_ENV
this.endpointDir = options.endpointDir ?? defaultEndpointDir()
this.endpointFilePath = join(this.endpointDir, getEndpointFileName())
this.fixedToken = options.token
this.preferredPort = options.preferredPort ?? 0
this.forward = options.forward
}
@ -127,10 +147,37 @@ export class RelayAgentHookServer {
if (this.server) {
return
}
this.token = randomUUID()
this.token = this.fixedToken ?? randomUUID()
this.endpointFileWritten = false
this.portFallbackApplied = false
try {
await this.listenOn(this.preferredPort)
} catch (err) {
// Why: the preferred port is best-effort (WSL relay: the Windows
// listener's port — occupied under mirrored networking, or by an
// unrelated guest process). Fall back to an ephemeral port; clients
// re-coordinate through the endpoint file.
if (this.preferredPort > 0 && (err as NodeJS.ErrnoException)?.code === 'EADDRINUSE') {
this.portFallbackApplied = true
await this.listenOn(0)
} else {
throw err
}
}
if (options.publishEndpoint !== false) {
this.publishEndpointFile()
}
}
/** True when the preferred port was occupied and the server fell back to
* an ephemeral bind diagnostics for the host-side relay manager. */
get usedPortFallback(): boolean {
return this.portFallbackApplied
}
private listenOn(port: number): Promise<void> {
this.server = createServer((req, res) => this.handleRequest(req, res))
await new Promise<void>((resolve, reject) => {
return new Promise<void>((resolve, reject) => {
const onStartupError = (err: Error): void => {
this.server?.off('listening', onListening)
// Why: null the server reference on bind failure so a subsequent
@ -149,16 +196,12 @@ export class RelayAgentHookServer {
if (address && typeof address === 'object') {
this.port = address.port
}
if (options.publishEndpoint !== false) {
this.publishEndpointFile()
}
resolve()
}
this.server!.once('error', onStartupError)
// Why: bind 127.0.0.1:0 so the OS assigns a free port. Loopback only —
// the agent CLI inside the same remote box reaches us via curl
// 127.0.0.1:PORT; nobody outside the box can.
this.server!.listen(0, '127.0.0.1', onListening)
// Why: loopback only — the agent CLI inside the same remote box reaches
// us via curl 127.0.0.1:PORT; nobody outside the box can.
this.server!.listen(port, '127.0.0.1', onListening)
})
}
@ -332,8 +375,19 @@ export class RelayAgentHookServer {
if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) {
this.clearAssistantMessageRetry(event.paneKey)
}
// Why: delete-then-set keeps Map insertion order equal to last-update
// recency, so the cache cap below always evicts the longest-idle pane.
this.state.lastStatusByPaneKey.delete(event.paneKey)
this.state.lastStatusByPaneKey.set(event.paneKey, event)
this.lastEnvelopeMetaByPaneKey.delete(event.paneKey)
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
while (this.state.lastStatusByPaneKey.size > MAX_CACHED_PANES) {
const oldest = this.state.lastStatusByPaneKey.keys().next().value
if (oldest === undefined) {
break
}
this.clearPaneState(oldest)
}
this.forwardEvent(event, source, env, version)
}

View File

@ -0,0 +1,195 @@
// Guest-side WSL hook relay pieces: host-given port/token binding with
// ephemeral fallback, and the shared endpoint-path contract.
import { createServer } from 'node:net'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RelayAgentHookServer } from './agent-hook-server'
import { makePaneKey } from '../shared/stable-pane-id'
import {
sanitizeWslHookInstanceKey,
wslHookRelayEndpointDir,
wslHookRelayEndpointFilePath
} from '../shared/wsl-hook-relay-contract'
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
const PANE_KEY = makePaneKey('tab-2', LEAF_ID)
// Parse an endpoint file tolerantly across platforms: POSIX writes `KEY=value`,
// Windows writes `set KEY=value`. Strips the optional `set ` prefix.
function parseEndpointFile(contents: string): Record<string, string> {
const env: Record<string, string> = {}
for (const rawLine of contents.split(/\r?\n/)) {
const line = rawLine.replace(/^set /, '')
const eq = line.indexOf('=')
if (eq > 0) {
env[line.slice(0, eq)] = line.slice(eq + 1)
}
}
return env
}
describe('RelayAgentHookServer host-given coordinates (WSL relay)', () => {
let tmpDir: string
let server: RelayAgentHookServer | null
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'wsl-hook-relay-'))
server = null
})
afterEach(() => {
server?.stop()
rmSync(tmpDir, { recursive: true, force: true })
})
it('binds the preferred port with the fixed token when the port is free', async () => {
const probe = createServer()
const freePort = await new Promise<number>((resolve) => {
probe.listen(0, '127.0.0.1', () => {
const address = probe.address()
resolve(typeof address === 'object' && address ? address.port : 0)
})
})
await new Promise<void>((resolve) => probe.close(() => resolve()))
server = new RelayAgentHookServer({
endpointDir: tmpDir,
token: 'host-issued-token',
preferredPort: freePort,
forward: () => {}
})
await server.start()
expect(server.getCoordinates().port).toBe(freePort)
expect(server.getCoordinates().token).toBe('host-issued-token')
expect(server.usedPortFallback).toBe(false)
})
it('falls back to an ephemeral port when the preferred port is occupied', async () => {
const occupant = createServer()
const occupiedPort = await new Promise<number>((resolve) => {
occupant.listen(0, '127.0.0.1', () => {
const address = occupant.address()
resolve(typeof address === 'object' && address ? address.port : 0)
})
})
try {
server = new RelayAgentHookServer({
endpointDir: tmpDir,
token: 'host-issued-token',
preferredPort: occupiedPort,
forward: () => {}
})
await server.start()
expect(server.usedPortFallback).toBe(true)
const bound = server.getCoordinates().port
expect(bound).toBeGreaterThan(0)
expect(bound).not.toBe(occupiedPort)
// Fallback still authenticates with the host-issued token.
expect(server.getCoordinates().token).toBe('host-issued-token')
} finally {
await new Promise<void>((resolve) => occupant.close(() => resolve()))
}
})
it('keeps random-token ephemeral behavior when no coordinates are given (SSH shape)', async () => {
server = new RelayAgentHookServer({ endpointDir: tmpDir, forward: () => {} })
await server.start()
expect(server.getCoordinates().port).toBeGreaterThan(0)
expect(server.getCoordinates().token).toMatch(/^[0-9a-f-]{36}$/)
expect(server.usedPortFallback).toBe(false)
})
it('rejects a wrong token but authenticates the host-issued fixed token', async () => {
const forward = vi.fn()
server = new RelayAgentHookServer({ endpointDir: tmpDir, token: 'host-issued-token', forward })
await server.start()
const { port } = server.getCoordinates()
const rejected = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': 'wrong-token' },
body: '{}'
})
expect(rejected.status).toBe(403)
expect(forward).not.toHaveBeenCalled()
const accepted = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': 'host-issued-token'
},
body: JSON.stringify({
paneKey: PANE_KEY,
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' }
})
})
expect(accepted.status).toBe(204)
expect(forward).toHaveBeenCalledTimes(1)
expect(forward.mock.calls[0][0].paneKey).toBe(PANE_KEY)
})
it('publishes the fallback port + fixed token into the endpoint file after EADDRINUSE', async () => {
const occupant = createServer()
const occupiedPort = await new Promise<number>((resolve) => {
occupant.listen(0, '127.0.0.1', () => {
const address = occupant.address()
resolve(typeof address === 'object' && address ? address.port : 0)
})
})
try {
server = new RelayAgentHookServer({
endpointDir: tmpDir,
token: 'host-issued-token',
preferredPort: occupiedPort,
forward: () => {}
})
await server.start()
expect(server.usedPortFallback).toBe(true)
const { port, token, endpointFilePath } = server.getCoordinates()
expect(port).not.toBe(occupiedPort)
// The endpoint file is the client re-coordination contract: surviving
// agents re-source it and MUST see the actual fallback port, not the
// occupied preferred port.
const published = parseEndpointFile(readFileSync(endpointFilePath, 'utf8'))
expect(published.ORCA_AGENT_HOOK_PORT).toBe(String(port))
expect(published.ORCA_AGENT_HOOK_PORT).not.toBe(String(occupiedPort))
expect(published.ORCA_AGENT_HOOK_TOKEN).toBe(token)
expect(published.ORCA_AGENT_HOOK_TOKEN).toBe('host-issued-token')
} finally {
await new Promise<void>((resolve) => occupant.close(() => resolve()))
}
})
})
describe('wsl hook relay endpoint contract', () => {
it('derives the endpoint dir from guest home and the restart-stable instance key', () => {
expect(wslHookRelayEndpointDir('/home/u', 'abc123')).toBe(
'/home/u/.orca-wsl/agent-hooks/instance-abc123'
)
expect(wslHookRelayEndpointDir('/home/u/', 'abc123')).toBe(
'/home/u/.orca-wsl/agent-hooks/instance-abc123'
)
})
it('names the guest endpoint file endpoint.env regardless of host platform', () => {
expect(wslHookRelayEndpointFilePath('/home/u', 'k1')).toBe(
'/home/u/.orca-wsl/agent-hooks/instance-k1/endpoint.env'
)
})
it('sanitizes instance keys to a shell/path-inert alphabet', () => {
expect(sanitizeWslHookInstanceKey('ABCdef012')).toBe('abcdef012')
expect(sanitizeWslHookInstanceKey(' a-1 ')).toBe('a-1')
expect(sanitizeWslHookInstanceKey('bad key$')).toBeNull()
expect(sanitizeWslHookInstanceKey('')).toBeNull()
expect(sanitizeWslHookInstanceKey(undefined)).toBeNull()
})
})

View File

@ -0,0 +1,114 @@
#!/usr/bin/env node
// Guest-resident WSL agent-hook relay (STA-1515). Runs inside a WSL distro,
// binds a loopback hook receiver on the very port the Windows host issued
// (free under NAT — that port only exists Windows-side), and forwards every
// parsed hook envelope to Orca over this process's own stdin/stdout using the
// framed JSON-RPC protocol the SSH relay already speaks. Also hosts the
// home-scoped fs bridge the host uses to install hook configs into the guest.
//
// Lifecycle: dies when stdin closes. A lingering guest listener would let
// WSL's Windows→WSL forwarder grab the freed Windows-side port and blackhole
// stale Windows-side hook posts — so unlike the SSH relay there is no grace
// period and no daemon socket.
import { homedir } from 'node:os'
import { RELAY_SENTINEL } from './protocol'
import { RelayDispatcher } from './dispatcher'
import { RelayAgentHookServer } from './agent-hook-server'
import { registerWslHookFsHandlers } from './wsl-hook-fs-bridge'
import {
AGENT_HOOK_NOTIFICATION_METHOD,
AGENT_HOOK_REQUEST_REPLAY_METHOD
} from '../shared/agent-hook-relay'
import {
sanitizeWslHookInstanceKey,
WSL_HOOK_RELAY_INSTANCE_ENV,
wslHookRelayEndpointDir
} from '../shared/wsl-hook-relay-contract'
async function main(): Promise<void> {
const windowsPort = Number(process.env.ORCA_AGENT_HOOK_PORT ?? '')
const token = process.env.ORCA_AGENT_HOOK_TOKEN ?? ''
if (!Number.isInteger(windowsPort) || windowsPort <= 0 || token.length === 0) {
process.stderr.write('[wsl-hook-relay] missing ORCA_AGENT_HOOK_PORT/TOKEN in env\n')
process.exit(1)
}
let stdoutAlive = true
const dispatcher = new RelayDispatcher((data) => {
if (!stdoutAlive) {
return
}
return process.stdout.write(data)
})
// Why: restart-stable instance key keeps the endpoint file at one path
// across app restarts so surviving agents re-coordinate off its rewrite.
const instanceKey =
sanitizeWslHookInstanceKey(process.env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? `port${windowsPort}`
const hookServer = new RelayAgentHookServer({
endpointDir: wslHookRelayEndpointDir(homedir(), instanceKey),
token,
preferredPort: windowsPort,
forward: (envelope) =>
dispatcher.notify(
AGENT_HOOK_NOTIFICATION_METHOD,
envelope as unknown as Record<string, unknown>
)
})
dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({
replayed: hookServer.replayCachedPayloadsForPanes()
}))
registerWslHookFsHandlers(dispatcher, homedir(), () => ({
portFallback: hookServer.usedPortFallback,
boundPort: hookServer.getCoordinates().port
}))
try {
await hookServer.start()
} catch (err) {
process.stderr.write(
`[wsl-hook-relay] hook server bind failed: ${err instanceof Error ? err.message : String(err)}\n`
)
process.exit(1)
}
if (hookServer.usedPortFallback) {
// Why: diagnosable breadcrumb — hook clients are fail-open silent, the
// relay must not be. Fallback is expected under mirrored networking.
process.stderr.write(
`[wsl-hook-relay] port ${windowsPort} occupied; bound ${hookServer.getCoordinates().port} (endpoint-file re-coordination)\n`
)
}
const shutdown = (): void => {
stdoutAlive = false
dispatcher.dispose()
hookServer.stop()
process.exit(0)
}
process.stdin.on('data', (chunk: Buffer) => dispatcher.feed(chunk))
process.stdin.on('end', shutdown)
process.stdin.on('error', shutdown)
process.stdout.on('error', shutdown)
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
// Why: same posture as the SSH relay — an uncaught exception may leave
// broken invariants, so exit and let the host manager respawn a clean
// relay; a stray rejection is logged and survived (hook delivery must not
// die for a non-fatal async error).
process.on('uncaughtException', (err) => {
process.stderr.write(`[wsl-hook-relay] uncaught exception: ${err.message}\n`)
process.exit(1)
})
process.on('unhandledRejection', (reason) => {
process.stderr.write(`[wsl-hook-relay] unhandled rejection: ${String(reason)}\n`)
})
// Signal readiness — the host watches for this exact string before
// sending framed data (same contract as the SSH relay).
process.stdout.write(RELAY_SENTINEL)
}
void main()

View File

@ -0,0 +1,143 @@
// Why: the fs bridge is POSIX-by-design — it resolves every path with
// `posix.resolve` against a POSIX guest home. On win32 `posix.resolve` of a
// Windows tmpdir yields an invalid path, so the whole suite is skipped there
// (the bridge only ever runs inside a Linux WSL guest).
import { mkdtempSync, rmSync, statSync } from 'node:fs'
import { posix } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { registerWslHookFsHandlers } from './wsl-hook-fs-bridge'
import type { MethodHandler, RelayDispatcher, RequestContext } from './dispatcher'
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../shared/wsl-hook-relay-contract'
describe.skipIf(process.platform === 'win32')('registerWslHookFsHandlers (WSL fs bridge)', () => {
let home: string
let handlers: Map<string, MethodHandler>
const context: RequestContext = { clientId: 1, isStale: () => false }
const call = async <T extends object = Record<string, unknown>>(
method: string,
params: Record<string, unknown> = {}
): Promise<WslFsResult<T>> => {
const handler = handlers.get(method)
if (!handler) {
throw new Error(`no handler registered for ${method}`)
}
return (await handler(params, context)) as WslFsResult<T>
}
beforeEach(() => {
home = mkdtempSync(posix.join(tmpdir(), 'wsl-fs-home-'))
handlers = new Map<string, MethodHandler>()
// Capture handlers from a minimal fake dispatcher — registration only ever
// calls onRequest, so a real RelayDispatcher is unnecessary.
const dispatcher = {
onRequest: (method: string, handler: MethodHandler) => {
handlers.set(method, handler)
}
} as unknown as RelayDispatcher
registerWslHookFsHandlers(dispatcher, home, () => ({ fallbackPort: 4321 }))
})
afterEach(() => {
rmSync(home, { recursive: true, force: true })
})
it('returns the resolved home and merges linkStatus extras', async () => {
const result = await call<{ home: string; fallbackPort: number }>(WSL_HOOK_FS_METHODS.home)
expect(result).toMatchObject({ ok: true, home: posix.resolve(home), fallbackPort: 4321 })
})
it('round-trips writeFile + readFile inside home', async () => {
const path = posix.join(home, 'note.txt')
const write = await call(WSL_HOOK_FS_METHODS.writeFile, { path, content: 'hello guest' })
expect(write.ok).toBe(true)
const read = await call<{ content: string }>(WSL_HOOK_FS_METHODS.readFile, { path })
expect(read).toEqual({ ok: true, content: 'hello guest' })
})
it('refuses writeFile to an absolute path outside home', async () => {
const result = await call(WSL_HOOK_FS_METHODS.writeFile, {
path: '/etc/orca-evil.txt',
content: 'x'
})
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
})
it('refuses a `..` traversal that escapes home', async () => {
const result = await call(WSL_HOOK_FS_METHODS.writeFile, {
path: `${home}/../escape.txt`,
content: 'x'
})
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
})
it('refuses a sibling dir that shares home as a string prefix', async () => {
// `${home}-evil/x` starts with homeRoot but not with `${homeRoot}/`.
const result = await call(WSL_HOOK_FS_METHODS.writeFile, {
path: `${home}-evil/x.txt`,
content: 'x'
})
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
})
it('reports ENOENT for a missing file inside home', async () => {
const result = await call(WSL_HOOK_FS_METHODS.readFile, {
path: posix.join(home, 'does-not-exist.txt')
})
expect(result).toMatchObject({ ok: false, errno: 'ENOENT' })
})
it('allows readdir existence probes on an ancestor of home and on /', async () => {
const ancestor = await call<{ entries: { filename: string }[] }>(WSL_HOOK_FS_METHODS.readdir, {
path: posix.dirname(home)
})
expect(ancestor.ok).toBe(true)
const root = await call<{ entries: { filename: string }[] }>(WSL_HOOK_FS_METHODS.readdir, {
path: '/'
})
expect(root.ok).toBe(true)
})
it('refuses readdir on a non-ancestor dir outside home', async () => {
const result = await call(WSL_HOOK_FS_METHODS.readdir, { path: '/etc' })
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
})
it('refuses rename crossing the home boundary in either direction', async () => {
const inside = posix.join(home, 'src.txt')
await call(WSL_HOOK_FS_METHODS.writeFile, { path: inside, content: 'x' })
const outbound = await call(WSL_HOOK_FS_METHODS.rename, {
src: inside,
dst: '/etc/orca-evil.txt'
})
expect(outbound).toMatchObject({ ok: false, errno: 'EACCES' })
const inbound = await call(WSL_HOOK_FS_METHODS.rename, {
src: '/etc/passwd',
dst: posix.join(home, 'stolen.txt')
})
expect(inbound).toMatchObject({ ok: false, errno: 'EACCES' })
})
it('refuses a relative path (resolved against cwd, which lands outside home)', async () => {
const result = await call(WSL_HOOK_FS_METHODS.readFile, { path: 'foo.txt' })
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
})
it('refuses mkdir outside home and creates a dir inside home', async () => {
const outside = await call(WSL_HOOK_FS_METHODS.mkdir, { path: '/etc/orca-evil-dir' })
expect(outside).toMatchObject({ ok: false, errno: 'EACCES' })
const dir = posix.join(home, 'newdir')
const inside = await call(WSL_HOOK_FS_METHODS.mkdir, { path: dir })
expect(inside.ok).toBe(true)
expect(statSync(dir).isDirectory()).toBe(true)
})
it('fails without crashing when chmod gets a non-numeric mode', async () => {
const path = posix.join(home, 'perm.txt')
await call(WSL_HOOK_FS_METHODS.writeFile, { path, content: 'x' })
const result = await call(WSL_HOOK_FS_METHODS.chmod, { path, mode: 'not-a-number' })
expect(result.ok).toBe(false)
})
})

View File

@ -0,0 +1,153 @@
// Guest-side fs bridge for the WSL agent-hook relay. Exposes the handful of
// home-scoped file operations the shared remote hook installers need, as
// JSON-RPC request handlers over the relay's stdio channel — so the host
// installs hook configs/scripts into the distro without per-file wsl.exe
// spawns (load-sensitive, see docs/agent-status-over-wsl.md).
import { promises as fs } from 'node:fs'
import { posix } from 'node:path'
import type { RelayDispatcher } from './dispatcher'
import {
WSL_HOOK_FS_METHODS,
type WslFsFailure,
type WslFsResult
} from '../shared/wsl-hook-relay-contract'
function failure(err: unknown): WslFsFailure {
const e = err as NodeJS.ErrnoException
return { ok: false, errno: e?.code ?? 'EUNKNOWN', message: e?.message ?? String(err) }
}
export function registerWslHookFsHandlers(
dispatcher: RelayDispatcher,
home: string,
// Why: the home request doubles as the connect handshake; link diagnostics
// (port fallback) ride it so the host can breadcrumb them.
linkStatus?: () => Record<string, unknown>
): void {
// Why: the bridge exists solely to write agent hook configs into the guest
// user's home. Refusing paths outside it bounds the blast radius of a
// compromised host-side caller to what hook installation touches anyway.
// The bound is lexical (symlinks inside home are followed) — acceptable
// because the only caller is the host-owned stdio channel, never an
// agent-reachable surface.
const homeRoot = posix.resolve(home)
const resolveRaw = (rawPath: unknown): string => {
if (typeof rawPath !== 'string' || rawPath.length === 0) {
throw Object.assign(new Error('invalid path'), { code: 'EINVAL' })
}
return posix.resolve(rawPath)
}
const scoped = (rawPath: unknown): string => {
const resolved = resolveRaw(rawPath)
if (resolved !== homeRoot && !resolved.startsWith(`${homeRoot}/`)) {
throw Object.assign(new Error(`path outside home: ${resolved}`), { code: 'EACCES' })
}
return resolved
}
// Why: the installers' mkdir-p walks top-down from `/`, probing every
// ancestor of home with readdir before it ever creates a dir. Allow
// read-only existence probes on those ancestors; everything else stays
// home-scoped.
const scopedProbe = (rawPath: unknown): string => {
const resolved = resolveRaw(rawPath)
if (resolved === '/' || homeRoot === resolved || homeRoot.startsWith(`${resolved}/`)) {
return resolved
}
return scoped(rawPath)
}
dispatcher.onRequest(
WSL_HOOK_FS_METHODS.home,
async (): Promise<WslFsResult<{ home: string }>> => {
return { ok: true, home: homeRoot, ...linkStatus?.() }
}
)
dispatcher.onRequest(
WSL_HOOK_FS_METHODS.readFile,
async (params): Promise<WslFsResult<{ content: string }>> => {
try {
const content = await fs.readFile(scoped(params.path), 'utf8')
return { ok: true, content }
} catch (err) {
return failure(err)
}
}
)
dispatcher.onRequest(WSL_HOOK_FS_METHODS.writeFile, async (params): Promise<WslFsResult> => {
try {
const mode = typeof params.mode === 'number' ? params.mode : undefined
await fs.writeFile(scoped(params.path), String(params.content ?? ''), {
encoding: 'utf8',
mode
})
return { ok: true }
} catch (err) {
return failure(err)
}
})
dispatcher.onRequest(
WSL_HOOK_FS_METHODS.stat,
async (params): Promise<WslFsResult<{ mode: number }>> => {
try {
const stats = await fs.stat(scoped(params.path))
return { ok: true, mode: stats.mode }
} catch (err) {
return failure(err)
}
}
)
dispatcher.onRequest(WSL_HOOK_FS_METHODS.rename, async (params): Promise<WslFsResult> => {
try {
// Why: POSIX rename overwrites atomically — exactly the OpenSSH
// overwrite-rename semantics the installers prefer.
await fs.rename(scoped(params.src), scoped(params.dst))
return { ok: true }
} catch (err) {
return failure(err)
}
})
dispatcher.onRequest(WSL_HOOK_FS_METHODS.unlink, async (params): Promise<WslFsResult> => {
try {
await fs.unlink(scoped(params.path))
return { ok: true }
} catch (err) {
return failure(err)
}
})
dispatcher.onRequest(WSL_HOOK_FS_METHODS.chmod, async (params): Promise<WslFsResult> => {
try {
await fs.chmod(scoped(params.path), Number(params.mode))
return { ok: true }
} catch (err) {
return failure(err)
}
})
dispatcher.onRequest(
WSL_HOOK_FS_METHODS.readdir,
async (params): Promise<WslFsResult<{ entries: { filename: string }[] }>> => {
try {
const names = await fs.readdir(scopedProbe(params.path))
return { ok: true, entries: names.map((filename) => ({ filename })) }
} catch (err) {
return failure(err)
}
}
)
dispatcher.onRequest(WSL_HOOK_FS_METHODS.mkdir, async (params): Promise<WslFsResult> => {
try {
await fs.mkdir(scoped(params.path))
return { ok: true }
} catch (err) {
return failure(err)
}
})
}

View File

@ -6009,6 +6009,138 @@ describe('useIpcEvents agent status snapshot integration', () => {
)
})
it('accepts WSL-relayed status events for a local repo (wsl:* is transport provenance, not ownership)', async () => {
const setAgentStatus = vi.fn()
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
current: null
}
const storeState: StoreLike = buildStoreState({
setAgentStatus,
workspaceSessionReady: true,
tabsByWorktree: {
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'WSL Tab' }]
},
terminalLayoutsByTabId: {
'tab-future': {
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
activeLeafId: FUTURE_LEAF_ID,
expandedLeafId: null
}
},
repos: [{ id: 'repo-1', connectionId: null }],
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => storeState
}
}))
stubAuxiliaryModules()
vi.stubGlobal(
'window',
buildWindowApi({
onSet: (cb) => {
onSetListenerRef.current = cb
return () => {}
}
})
)
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
if (typeof onSetListenerRef.current !== 'function') {
throw new Error('Expected agentStatus.onSet listener to be registered')
}
onSetListenerRef.current({
paneKey: FUTURE_PANE_KEY,
state: 'working',
prompt: 'wsl p',
agentType: 'claude',
worktreeId: 'wt-1',
connectionId: 'wsl:Ubuntu',
receivedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000
})
expect(setAgentStatus).toHaveBeenCalledTimes(1)
expect(setAgentStatus).toHaveBeenCalledWith(
FUTURE_PANE_KEY,
expect.objectContaining({ state: 'working', prompt: 'wsl p', agentType: 'claude' }),
'WSL Tab',
{ updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 },
expectWorktreeRouting('wt-1'),
undefined
)
})
it('still rejects WSL-relayed status events against an SSH-owned repo', async () => {
const setAgentStatus = vi.fn()
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
current: null
}
const storeState: StoreLike = buildStoreState({
setAgentStatus,
workspaceSessionReady: true,
tabsByWorktree: {
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'SSH Tab' }]
},
terminalLayoutsByTabId: {
'tab-future': {
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
activeLeafId: FUTURE_LEAF_ID,
expandedLeafId: null
}
},
repos: [{ id: 'repo-1', connectionId: 'ssh-1' }],
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => storeState
}
}))
stubAuxiliaryModules()
vi.stubGlobal(
'window',
buildWindowApi({
onSet: (cb) => {
onSetListenerRef.current = cb
return () => {}
}
})
)
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
if (typeof onSetListenerRef.current !== 'function') {
throw new Error('Expected agentStatus.onSet listener to be registered')
}
onSetListenerRef.current({
paneKey: FUTURE_PANE_KEY,
state: 'working',
prompt: 'wsl p',
agentType: 'claude',
worktreeId: 'wt-1',
connectionId: 'wsl:Ubuntu',
receivedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000
})
expect(setAgentStatus).not.toHaveBeenCalled()
})
it('still rejects remote status events once the pane resolves to a local repo', async () => {
const setAgentStatus = vi.fn()
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {

View File

@ -29,6 +29,7 @@ import type {
} from '../../../shared/remote-workspace-types'
import type { RateLimitState } from '../../../shared/rate-limit-types'
import type { SshConnectionState } from '../../../shared/ssh-types'
import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract'
import type {
RuntimeBrowserDriverState,
RuntimeTerminalPresentation,
@ -2988,15 +2989,23 @@ export function useIpcEvents(): void {
// matches that tab's worktree, accept the status until repo ownership
// becomes available; once ownership is resolved, keep the strict
// connectionId check below.
// Why: the WSL hook relay stamps a transport-provenance connectionId
// (`wsl:<distro>`), but the pane is a LOCAL pane on a local repo —
// ownership-wise it is null. Without this normalization the strict
// check below drops every WSL-relayed status for a local repo (while
// still rejecting WSL-stamped events against SSH-owned repos).
const ownershipConnectionId = isWslHookRelayConnectionId(data.connectionId)
? null
: data.connectionId
const canAcceptPendingRemoteOwnership =
data.connectionId !== undefined &&
data.connectionId !== null &&
ownershipConnectionId !== undefined &&
ownershipConnectionId !== null &&
!repoConnectionResolved &&
data.worktreeId !== undefined &&
data.worktreeId === owningWorktreeId
if (
data.connectionId !== undefined &&
data.connectionId !== repoConnectionId &&
ownershipConnectionId !== undefined &&
ownershipConnectionId !== repoConnectionId &&
!canAcceptPendingRemoteOwnership
) {
return 'dropped'

View File

@ -0,0 +1,88 @@
// Shared contract between the Windows host and the guest-resident WSL
// agent-hook relay. Both sides derive paths/methods from here so the guest
// process and the host manager can never drift on where the relay lives,
// which JSON-RPC methods the fs bridge speaks, or which exit codes signal
// "reinstall me" vs "no usable node".
// See docs/agent-status-over-wsl.md (STA-1515).
/** Guest-side install dir for the relay bundle, relative to `$HOME`. */
export const WSL_HOOK_RELAY_DIR = '.orca-wsl/hook-relay'
export const WSL_HOOK_RELAY_BUNDLE_NAME = 'wsl-agent-hook-relay.js'
export const WSL_HOOK_RELAY_VERSION_FILE = '.version'
/** Host-expected bundle version, crossed into the guest launch script via
* WSLENV so a stale guest install is detected by the guest itself. Also
* namespaces the guest install dir, so concurrent Orca instances with
* different bundle versions (dev + prod) never reinstall over each other. */
export const WSL_HOOK_RELAY_VERSION_ENV = 'ORCA_WSL_HOOK_RELAY_VERSION'
/** Stable per-instance identity for the guest endpoint dir, crossed via
* WSLENV. Derived from the Windows endpoint file path (userData +
* namespace), NOT the hook port: the port changes every app launch, and a
* port-keyed dir would leave daemon-surviving agents sourcing a stale file
* after an Orca restart the exact re-coordination this exists to serve. */
export const WSL_HOOK_RELAY_INSTANCE_ENV = 'ORCA_WSL_HOOK_INSTANCE'
/** Launch-script exit codes. 42 mirrors the SSH relay's handshake-mismatch
* convention: the host reinstalls the bundle and relaunches once. */
export const WSL_HOOK_RELAY_STALE_EXIT_CODE = 42
export const WSL_HOOK_RELAY_NO_NODE_EXIT_CODE = 43
/** JSON-RPC methods for the relay's home-scoped fs bridge. The host runs the
* unchanged SSH remote hook installers against these via an SFTP-shaped
* adapter, so hook installation rides the already-open stdio channel instead
* of per-file wsl.exe spawns. */
export const WSL_HOOK_FS_METHODS = {
home: 'wslfs.home',
readFile: 'wslfs.readFile',
writeFile: 'wslfs.writeFile',
stat: 'wslfs.stat',
rename: 'wslfs.rename',
unlink: 'wslfs.unlink',
chmod: 'wslfs.chmod',
readdir: 'wslfs.readdir',
mkdir: 'wslfs.mkdir'
} as const
/** Result envelope for every fs-bridge method. Errors travel as data (not
* JSON-RPC faults) so the host adapter can map POSIX errno onto the ssh2
* status codes the shared installer error-classifiers already understand. */
export type WslFsFailure = { ok: false; errno: string; message: string }
export type WslFsResult<T extends object = object> = ({ ok: true } & T) | WslFsFailure
/** Where the guest relay publishes its endpoint file. Keyed by the stable
* instance key (restart-stable, instance-unique), so a restarted instance's
* relay REWRITES the same file that surviving agents' env already names
* that rewrite is what re-coordinates them onto fresh port/token. */
export function wslHookRelayEndpointDir(guestHome: string, instanceKey: string): string {
const home = guestHome.endsWith('/') ? guestHome.slice(0, -1) : guestHome
return `${home}/.orca-wsl/agent-hooks/instance-${instanceKey}`
}
/** Keep instance keys shell/path-inert on both sides of the boundary. */
export function sanitizeWslHookInstanceKey(value: string | undefined): string | null {
const trimmed = value?.trim().toLowerCase() ?? ''
return /^[a-z0-9][a-z0-9-]{0,63}$/.test(trimmed) ? trimmed : null
}
/** The guest is always POSIX, so the Windows host must name the guest's
* endpoint file explicitly its own `getEndpointFileName()` would say
* `endpoint.cmd`. Matches the POSIX branch of that helper. */
export const WSL_HOOK_RELAY_ENDPOINT_FILE = 'endpoint.env'
/** connectionId stamped on WSL-relayed hook envelopes. Transport provenance
* only: the pane is a LOCAL pane on a local repo, so ownership checks must
* treat these ids as local (null), not as a remote connection. */
export const WSL_HOOK_RELAY_CONNECTION_PREFIX = 'wsl:'
export function wslHookRelayConnectionId(distro: string): string {
return `${WSL_HOOK_RELAY_CONNECTION_PREFIX}${distro}`
}
export function isWslHookRelayConnectionId(value: string | null | undefined): boolean {
return typeof value === 'string' && value.startsWith(WSL_HOOK_RELAY_CONNECTION_PREFIX)
}
export function wslHookRelayEndpointFilePath(guestHome: string, instanceKey: string): string {
return `${wslHookRelayEndpointDir(guestHome, instanceKey)}/${WSL_HOOK_RELAY_ENDPOINT_FILE}`
}