From f238952be2caabc63a35dece8c45bebb14f3d299 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:19:45 -0700 Subject: [PATCH] Agent status over WSL: guest-resident hook relay + WSL-side hook installers (STA-1515) (#7903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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:' !== 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 * 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 * docs: record round-3 rig validation status for agent status over WSL (STA-1515) Co-authored-by: Orca * 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 Co-authored-by: Brennan Benson --- config/scripts/build-relay.mjs | 27 ++ docs/agent-status-over-wsl.md | 374 +++++++++++++++++ .../remote-hook-service-installers.test.ts | 42 ++ .../remote-managed-hook-installers.ts | 31 +- src/main/agent-hooks/wsl-hook-fs-adapter.ts | 136 ++++++ src/main/agent-hooks/wsl-hook-relay-deps.ts | 90 ++++ src/main/agent-hooks/wsl-hook-relay-launch.ts | 330 +++++++++++++++ src/main/agent-hooks/wsl-hook-relay-link.ts | 58 +++ .../wsl-hook-relay-live.integration.test.ts | 203 +++++++++ .../wsl-hook-relay-manager.test.ts | 389 ++++++++++++++++++ .../agent-hooks/wsl-hook-relay-manager.ts | 328 +++++++++++++++ .../wsl-hook-relay-recovery.test.ts | 155 +++++++ .../agent-hooks/wsl-hook-relay-recovery.ts | 81 ++++ .../wsl-hook-relay-sentinel.test.ts | 152 +++++++ .../agent-hooks/wsl-hook-relay-sentinel.ts | 159 +++++++ .../codex-accounts/runtime-home-service.ts | 5 +- src/main/codex/hook-service.ts | 34 +- src/main/index.ts | 4 + src/main/ipc/pty.ts | 21 + src/main/pty/codex-home-wsl-env.ts | 16 + src/main/pty/wsl-orca-env.test.ts | 21 + src/main/pty/wsl-orca-env.ts | 8 +- src/relay/agent-hook-server.test.ts | 47 +++ src/relay/agent-hook-server.ts | 72 +++- src/relay/wsl-agent-hook-relay.test.ts | 195 +++++++++ src/relay/wsl-agent-hook-relay.ts | 114 +++++ src/relay/wsl-hook-fs-bridge.test.ts | 143 +++++++ src/relay/wsl-hook-fs-bridge.ts | 153 +++++++ src/renderer/src/hooks/useIpcEvents.test.ts | 132 ++++++ src/renderer/src/hooks/useIpcEvents.ts | 17 +- src/shared/wsl-hook-relay-contract.ts | 88 ++++ 31 files changed, 3600 insertions(+), 25 deletions(-) create mode 100644 docs/agent-status-over-wsl.md create mode 100644 src/main/agent-hooks/wsl-hook-fs-adapter.ts create mode 100644 src/main/agent-hooks/wsl-hook-relay-deps.ts create mode 100644 src/main/agent-hooks/wsl-hook-relay-launch.ts create mode 100644 src/main/agent-hooks/wsl-hook-relay-link.ts create mode 100644 src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts create mode 100644 src/main/agent-hooks/wsl-hook-relay-manager.test.ts create mode 100644 src/main/agent-hooks/wsl-hook-relay-manager.ts create mode 100644 src/main/agent-hooks/wsl-hook-relay-recovery.test.ts create mode 100644 src/main/agent-hooks/wsl-hook-relay-recovery.ts create mode 100644 src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts create mode 100644 src/main/agent-hooks/wsl-hook-relay-sentinel.ts create mode 100644 src/relay/wsl-agent-hook-relay.test.ts create mode 100644 src/relay/wsl-agent-hook-relay.ts create mode 100644 src/relay/wsl-hook-fs-bridge.test.ts create mode 100644 src/relay/wsl-hook-fs-bridge.ts create mode 100644 src/shared/wsl-hook-relay-contract.ts diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index da33ccb07..c9cabcea8 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -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.') diff --git a/docs/agent-status-over-wsl.md b/docs/agent-status-over-wsl.md new file mode 100644 index 000000000..e5a6ac9aa --- /dev/null +++ b/docs/agent-status-over-wsl.md @@ -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/`. + +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\\...` 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 -- 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//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:" !== 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 -- ` 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`. diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 550af50bc..795395999 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -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 + } + 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') diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index e68ec2a8d..0151f6375 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -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 + ( + sftp: SFTPWrapper, + remoteHome: string, + options?: RemoteManagedHookInstallOptions + ) => Promise ] 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 { 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( diff --git a/src/main/agent-hooks/wsl-hook-fs-adapter.ts b/src/main/agent-hooks/wsl-hook-fs-adapter.ts new file mode 100644 index 000000000..d47dd4001 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-fs-adapter.ts @@ -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 { + 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 = (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 = { + 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 = ( + method: string, + params: Record, + callback: SftpCallback, + pick: (result: { ok: true } & Wire) => Value + ): void => { + mux + .request(method, params) + .then((raw) => { + const result = raw as WslFsResult + 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, + callback: SftpCallback + ): void => { + call, undefined>(method, params, callback, () => undefined) + } + + const adapter = { + readFile(path: string, _encoding: unknown, callback: SftpCallback): 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 +} diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts new file mode 100644 index 000000000..2c30739e2 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -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 + /** Restart-stable, instance-unique key for the guest endpoint dir. */ + instanceKey: () => string | null + resolveBundle: typeof resolveWslHookRelayBundle + readBundle: (jsPath: string) => Buffer + listDistros: () => Promise + isDistroRunning: typeof isWslDistroRunning + spawnRelay: typeof spawnWslRelayProcess + runInstall: typeof runWslInstallProcess + waitForSentinel: typeof waitForWslRelaySentinel + ingest: (envelope: Record, 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[0], + connectionId + ), + installHooks: installRemoteManagedAgentHooks, + warn: (message) => console.warn(message), + transientRetryDelayMs: WSL_RELAY_TRANSIENT_RETRY_DELAY_MS +} diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts new file mode 100644 index 000000000..72ff3bb0c --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -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 { + 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 +}): Promise { + 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, + 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) + return env +} diff --git a/src/main/agent-hooks/wsl-hook-relay-link.ts b/src/main/agent-hooks/wsl-hook-relay-link.ts new file mode 100644 index 000000000..2e9fede95 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-link.ts @@ -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, 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')) +} diff --git a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts new file mode 100644 index 000000000..89b0987a5 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts @@ -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 { + const probe = createServer() + const port = await new Promise((resolve) => { + probe.listen(0, '127.0.0.1', () => { + const address = probe.address() + resolve(typeof address === 'object' && address ? address.port : 0) + }) + }) + await new Promise((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[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): Promise => + 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) + } +) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts new file mode 100644 index 000000000..7e341d408 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -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((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((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((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((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((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): { + 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[] = [] + 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 => { + for (let i = 0; i < 25; i++) { + await Promise.resolve() + } + } + let resolveProbe: ((running: boolean) => void) | undefined + const isDistroRunning = vi.fn(() => new Promise((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(() => {})) + 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() + } + }) +}) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts new file mode 100644 index 000000000..0c70d3a48 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -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 + reinstallTimer?: ReturnType + lastInstallAt?: number +} + +function distroKey(distro: string): string { + return distro.trim().toLowerCase() +} + +export class WslHookRelayManager { + private deps: WslHookRelayManagerDeps + private recovery: WslRelayRecovery + private states = new Map() + private defaultDistro: string | null = null + private disposed = false + private warnedBundleMissing = false + + constructor(deps: Partial = {}) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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() diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts new file mode 100644 index 000000000..018d91019 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts @@ -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 { + 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((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((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() + }) +}) diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.ts new file mode 100644 index 000000000..71c8b4a22 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.ts @@ -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 + reinstallTimer?: ReturnType +} + +export type WslRelayRecoveryIo = { + isDistroRunning: (distro: string) => Promise + 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 { + 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) + } +} diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts new file mode 100644 index 000000000..a80854855 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts @@ -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 } + +function fakeChild(): FakeChild { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: { write: ReturnType } + kill: ReturnType + } + 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 +): Promise { + 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((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)) + }) +}) diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.ts new file mode 100644 index 000000000..fdabf70fe --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.ts @@ -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 { + 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}` : ''}` +} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 754a0f111..2c6259dd9 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -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( diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 31e552df5..66b2b65b0 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1184,9 +1184,25 @@ export class CodexHookService { return this.getStatus() } - async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { - 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 { + 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) diff --git a/src/main/index.ts b/src/main/index.ts index 0351ecf1d..1b91bf369 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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. diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c6c0c6c59..588df2d57 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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?.() }) diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index b642e7661..f61272de8 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -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) { diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 9d7c36c6c..550295460 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -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 = { + 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 = { + 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 = { ORCA_WSL_HOOK_RELAY_VERSION: '0.1.0+abc' } + addOrcaWslInteropEnv(env) + expect(env.WSLENV).toBe('ORCA_WSL_HOOK_RELAY_VERSION/u') + }) }) diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 40a694a87..161621e8c 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -16,6 +16,10 @@ function upsertWslenvEntry(entries: string[], entry: string): void { export function addOrcaWslInteropEnv(env: Record): 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): 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' ] diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 33f630d9c..442052c1e 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -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 => + 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) }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 769921f59..741a2a5ca 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -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>() 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 { this.server = createServer((req, res) => this.handleRequest(req, res)) - await new Promise((resolve, reject) => { + return new Promise((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) } diff --git a/src/relay/wsl-agent-hook-relay.test.ts b/src/relay/wsl-agent-hook-relay.test.ts new file mode 100644 index 000000000..c72f21462 --- /dev/null +++ b/src/relay/wsl-agent-hook-relay.test.ts @@ -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 { + const env: Record = {} + 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((resolve) => { + probe.listen(0, '127.0.0.1', () => { + const address = probe.address() + resolve(typeof address === 'object' && address ? address.port : 0) + }) + }) + await new Promise((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((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((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((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((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() + }) +}) diff --git a/src/relay/wsl-agent-hook-relay.ts b/src/relay/wsl-agent-hook-relay.ts new file mode 100644 index 000000000..3411cb690 --- /dev/null +++ b/src/relay/wsl-agent-hook-relay.ts @@ -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 { + 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 + ) + }) + + 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() diff --git a/src/relay/wsl-hook-fs-bridge.test.ts b/src/relay/wsl-hook-fs-bridge.test.ts new file mode 100644 index 000000000..f656b4aa3 --- /dev/null +++ b/src/relay/wsl-hook-fs-bridge.test.ts @@ -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 + const context: RequestContext = { clientId: 1, isStale: () => false } + + const call = async >( + method: string, + params: Record = {} + ): Promise> => { + const handler = handlers.get(method) + if (!handler) { + throw new Error(`no handler registered for ${method}`) + } + return (await handler(params, context)) as WslFsResult + } + + beforeEach(() => { + home = mkdtempSync(posix.join(tmpdir(), 'wsl-fs-home-')) + handlers = new Map() + // 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) + }) +}) diff --git a/src/relay/wsl-hook-fs-bridge.ts b/src/relay/wsl-hook-fs-bridge.ts new file mode 100644 index 000000000..3ebb6047b --- /dev/null +++ b/src/relay/wsl-hook-fs-bridge.ts @@ -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 +): 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> => { + return { ok: true, home: homeRoot, ...linkStatus?.() } + } + ) + + dispatcher.onRequest( + WSL_HOOK_FS_METHODS.readFile, + async (params): Promise> => { + 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 => { + 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> => { + 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 => { + 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 => { + 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 => { + 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> => { + 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 => { + try { + await fs.mkdir(scoped(params.path)) + return { ok: true } + } catch (err) { + return failure(err) + } + }) +} diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 1cebfdce6..f8d46e38b 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -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 } = { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index debbe00a5..80e5c2e30 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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:`), 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' diff --git a/src/shared/wsl-hook-relay-contract.ts b/src/shared/wsl-hook-relay-contract.ts new file mode 100644 index 000000000..ea1d763d6 --- /dev/null +++ b/src/shared/wsl-hook-relay-contract.ts @@ -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 = ({ 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}` +}