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}` +}