From 5d2c298937834b75ff9514487cd06b64da4ecfc5 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Wed, 2 Sep 2026 18:47:35 -0700 Subject: [PATCH] feat(wsl): route foreground identity through resident relay --- docs/plans/wsl-relay-plan.md | 452 ++++++++++++++++++ src/main/agent-hooks/wsl-hook-relay-deps.ts | 13 + .../wsl-hook-relay-guest-install.ts | 14 + .../agent-hooks/wsl-hook-relay-identity.ts | 53 ++ src/main/agent-hooks/wsl-hook-relay-launch.ts | 18 +- .../agent-hooks/wsl-hook-relay-lifecycle.ts | 173 +++++++ .../wsl-hook-relay-manager.test.ts | 19 +- .../agent-hooks/wsl-hook-relay-manager.ts | 334 ++++++------- .../daemon/daemon-pty-session-inventory.ts | 84 ++-- src/main/ipc/pty/host-env/assembly.ts | 10 +- .../local-pty-foreground-inspection.ts | 40 +- .../providers/local-pty-provider-state.ts | 4 +- .../providers/local-pty-session-operations.ts | 75 ++- .../providers/wsl-guest-process-inventory.ts | 18 +- .../wsl-relay-identity-reader.test.ts | 13 + .../providers/wsl-relay-identity-reader.ts | 85 ++++ src/main/pty/wsl-orca-env.ts | 2 + .../rpc/methods/worktree-catalog-methods.ts | 6 +- .../startup/main-process-ready-foundation.ts | 12 +- src/relay/wsl-agent-hook-relay.ts | 95 +++- src/relay/wsl-relay-process.ts | 208 ++++++++ ...wsl-guest-foreground-process-resolution.ts | 4 +- .../wsl-guest-process-inventory-parser.ts | 0 src/shared/wsl-hook-relay-contract.ts | 33 ++ 24 files changed, 1389 insertions(+), 376 deletions(-) create mode 100644 docs/plans/wsl-relay-plan.md create mode 100644 src/main/agent-hooks/wsl-hook-relay-identity.ts create mode 100644 src/main/agent-hooks/wsl-hook-relay-lifecycle.ts create mode 100644 src/main/providers/wsl-relay-identity-reader.test.ts create mode 100644 src/main/providers/wsl-relay-identity-reader.ts create mode 100644 src/relay/wsl-relay-process.ts rename src/{main/providers => shared}/wsl-guest-foreground-process-resolution.ts (96%) rename src/{main/providers => shared}/wsl-guest-process-inventory-parser.ts (100%) diff --git a/docs/plans/wsl-relay-plan.md b/docs/plans/wsl-relay-plan.md new file mode 100644 index 00000000000..0d3df6d5a14 --- /dev/null +++ b/docs/plans/wsl-relay-plan.md @@ -0,0 +1,452 @@ +# WSL resident relay and foreground identity plan + +Status: implemented in the resident WSL relay; real-WSL hardware validation is +outstanding as a separate follow-up. + +## Decision and outcome + +Turn the current hooks relay into a multi-capability relay: one long-lived, +versioned Node process in every resolvable WSL distro and Orca instance. Its +existing hook receiver and home-scoped filesystem bridge remain capabilities of +the relay, while process/identity reads become a third capability and future +in-distro work can be added behind the same negotiated protocol. + +The Windows side uses `wsl.exe` only to discover, ensure, and launch the agent. +Once the stdio channel is ready, hook, filesystem, and process work use framed +JSON-RPC over that channel. In particular, a foreground-process observation +does not start a new `wsl.exe` process. Idle means zero identity work: no +process snapshot, identity RPC, or per-event `wsl.exe` invocation. A live +transport's already-existing framing keepalive is control-plane maintenance, +not an identity poll. + +The relay name is deliberately not hooks-specific: + +- Product/process name: **Orca WSL relay**. +- Entry point: `src/relay/wsl-agent-hook-relay.ts` (compatibility bundle name). +- Host owner: `WslHookRelayManager` (with lifecycle, launch, link, recovery, + and contract modules split by responsibility). +- Guest install root: `.orca-wsl/hook-relay//`. + +The existing endpoint directory under `.orca-wsl/agent-hooks/` remains a +compatibility path while managed hook writers migrate. The process and bundle +retain relay naming in user-facing text, telemetry, and APIs. + +No user command is changed. There is no shim, required launch flag, or +user-visible wrapper. The agent is an Orca-owned helper launched beside the +WSL PTY, not a replacement for the user's shell or agent command. + +## Boundaries and scope + +### Relay capabilities + +The relay owns work whose truth or filesystem is inside the distro: + +1. The loopback hook receiver, including the existing token, endpoint-file + publication, replay behavior, and fail-open hook HTTP handling. +2. The home-scoped filesystem bridge used by managed hook and plugin + installers. Path containment, errno mapping, and the existing SFTP-shaped + methods stay in the guest boundary; there is no general arbitrary-exec RPC. +3. A process/identity capability that reads one coherent guest observation: + distro boot id, process table, `/proc` start ticks, tty, process groups, and + command lines, then resolves requested PTY anchors. +4. Health and capability negotiation, including the bundle/protocol version, + readiness, request deadlines, and bounded response sizes. +5. Future in-guest capabilities such as a structured environment probe or + guest-local metadata read, each as an explicitly named, versioned method. + A future capability must not smuggle a per-operation `wsl.exe` path back + into a caller. + +The relay never owns Windows settings, Orca's pane/status model, +the WSL distro catalog, or renderer policy. It cannot decide that a process is +gone merely because its own connection disappeared. + +### Host capabilities + +The Windows host owns: + +- the WSL distro catalog and capability policy; +- deciding which distro/instance needs an agent and ensuring it; +- bundle selection, extraction, locks, protocol negotiation, retries, + telemetry, and user-facing diagnostics; +- associating a WSL PTY with its guest shell anchor and current PTY + incarnation; +- applying the guest's structured identity result to pane status, completion, + and publication; and +- the fallback policy when the agent cannot be reached. + +The host remains authoritative for a Windows-native PTY. The guest is not a +general execution host for arbitrary Orca operations: commands, shell input, +and PTY lifecycle still run through the normal WSL PTY path. + +### Cross-platform boundary + +This is a WSL-specific adapter, enabled only on Windows when a PTY has a WSL +distro context. Native Windows panes continue to use the native process-table +and ConPTY/job evidence. macOS and Linux panes continue to use their native +POSIX providers and do not install a WSL relay. SSH panes use the execution +host's SSH relay/provider; a local Windows WSL agent must never inspect or stand +in for an SSH host. A Windows machine reached over SSH is not thereby a WSL +target and remains subject to that SSH provider's measured identity support. +Folder workspaces follow the same execution-host rule and do not become git +worktree assumptions. + +## Ensure and lifecycle + +### Ensure state machine + +`ensure(distro, reason)` is called from WSL PTY creation/reattach and from an +identity request that has a live WSL pane. It is not called by a periodic +identity timer. The manager key is the normalized distro plus the stable +Orca-instance key, so case variants of a distro coalesce but two Orca +instances have independent control channels and endpoint identities. + +Each manager key has one in-memory `ensure` promise. Every caller joins that +promise; a failed attempt leaves a bounded backoff state rather than starting a +second launch. The guest filesystem has a second, cross-process install lock so +two Orca instances (or an app restart racing the old process) cannot extract +over one another. The lock is an atomic directory or equivalent, records the +guest PID and owner nonce, detects a dead owner, and has a bounded wait. A +partial tree is never published as usable. + +The serialized ensure sequence is: + +1. Check Windows, WSL availability, and a resolvable non-empty distro. For + recovery, query `wsl --list --running` first; this + check must not boot a stopped distro. +2. Resolve the packaged agent bundle and its semantic protocol version. Build + an instance-specific environment through `WSLENV`; do not put a script or + secret in a user command's argv. +3. Run a short, machine-readable preflight through + `wsl.exe -d --exec sh -s`. It probes `HOME`, the version marker, + the installed tree, and a usable Node (18 or newer), and reports a fenced + result. `--exec` is mandatory; never use the bare `--` form. A captured + login-shell command uses the existing nonce fence so banners cannot be + mistaken for the result. +4. If the exact version-keyed tree is absent or incomplete, acquire the guest + install lock and stream an idempotent extraction script through + `wsl.exe --exec sh -s`. Write bundle/launcher files to PID-unique temporary + names, verify their bytes, atomically rename them, and write the version + marker last. Release the lock only after a complete tree is visible. An + existing complete tree is reused without extraction. +5. Spawn the long-lived agent with the existing WSL interop boundary (an + explicit `wsl.exe --exec sh ...` launch, not a user-shell command), wait for + its exact readiness sentinel, and perform a protocol/capability handshake + before sending requests. The preflight/extraction lane is the only `sh -s` + data crossing; process, hook, fs, and health operations use the open stdio + channel. +6. Ask for the guest home/endpoint coordinates, then publish the endpoint and + mark the state `running`. Hook installation and plugin overlay work are + follow-on operations and must not block the agent's health/identity method + indefinitely. + +The manager keeps the current recovery properties: startup failures use a +bounded exponential cooldown; a missing Node has a long cooldown; a stable run +resets the failure count; and a dead running link receives a bounded restart +attempt. A request deadline or a dead transport cancels in-flight work before +the child is replaced. No recovery timer may boot a distro that is not listed +as running. + +### Deliberate death and app lifecycle + +The relay dies when its stdin closes, as the current relay deliberately +does. A lingering guest listener could let WSL's Windows-to-WSL forwarding +path claim a freed host port and blackhole stale hook posts; a grace period or +detached daemon would make that failure silent. The agent also exits on stdout +error, SIGTERM/SIGINT, an unrecoverable protocol error, or an uncaught +exception. The host closes the mux and kills the child on every teardown. + +On a normal app restart, the old manager closes stdin/child, and the new manager +reuses the restart-stable instance key but obtains fresh hook coordinates. The +new agent rewrites the same guest endpoint file with the fresh port/token; +surviving hook clients therefore re-coordinate without a stale path. An +instance generation/owner nonce in the endpoint and handshake prevents an old +agent from being accepted after a replacement. If an old child survives an +unclean app crash, the new handshake treats it as a competing owner, closes it +when possible, and never sends identity requests to an unnegotiated process. + +When a distro is stopped, restarted, or terminated by `wsl --shutdown`, the +stdio child and mux are expected to close. The manager records the link as +failed/unavailable, clears request state, and waits for a later WSL PTY spawn or +explicit user action. Recovery must not invoke `wsl -d` merely to resurrect a +distro the user shut down. A restart of the distro gives a new boot id; all +old guest shell anchors and observations are consequently invalid and require +a fresh shell marker and a fresh agent request. + +When the user removes a distro, the next list/preflight failure drops only that +distro's state, endpoint metadata, timers, and cached observations. It does not +try another distro or repeatedly recreate the removed one. A newly created +distro is a new identity and goes through ensure and fresh anchors. + +## Capability gating and hooks-off behavior + +Relay residency is gated only by Windows (`process.platform === 'win32'`) and +a resolvable, non-empty WSL distro. Ensure is not gated on +`remoteHooksEnabled`, `agentStatusHooksEnabled`, managed hook settings, or the +list of enabled integrations. The relay is deployed/ensured in every distro; +there is no consent state, one-time dialog, or unknown tri-state. + +The hook capability alone respects `agentStatusHooksEnabled !== false` and the +remote hooks setting. When either setting opts out, the relay remains present +for health, home-scoped fs, and process/identity reads, but its hook capability +is inert: no hook configuration is installed or refreshed, no hook posts are +published, and no hook events are wired. Re-enabling hooks resumes the existing +idempotent installer path. + +The foreground-process/identity capability is always active regardless of hook +settings. If the relay is unavailable, identity is `unverifiable`; title may +still be shown as the absolute last display fallback but cannot become process +evidence. Residency changes are not inferred from a missing distro or failed +request. + +## Protocol, versioning, and mixed versions + +The relay has a small handshake before normal JSON-RPC: + +```text +client -> hello { protocolMajor, protocolMinor, bundleVersion, instanceKey, + requestedCapabilities } +agent -> ready { protocolMajor, protocolMinor, bundleVersion, + agreedMinor, capabilities, agentGeneration } +``` + +The semantic protocol version governs compatibility; the bundle version also +identifies the exact shipped tree. A major mismatch is refused explicitly. +Additive methods/optional fields use a negotiated minor capability. The host +never sends a method merely because JSON-RPC would technically carry it: it +first checks the agent's advertised capability. Unknown methods and malformed +responses become visible, bounded errors rather than an apparent empty result. + +The guest launcher compares the expected bundle version with the version +marker. A missing marker, incomplete tree, or mismatch exits with a distinct +stale-agent result. The host then performs exactly one serialized ensure and +relaunch for that generation. Extraction is version-keyed, atomic, and +content-complete; old version directories are retained while an instance has a +live process and garbage-collected only after no endpoint/owner references +remain. Never overwrite a running version directory in place. + +Mixed versions are normal: + +- An older relay may continue to serve the existing hook/fs methods to an + older Orca instance. A newer host that negotiates with it but sees no + `process.identity` capability keeps hooks/fs compatibility and marks WSL + identity `unverifiable`; it does not silently use a bare name or spawn + `wsl.exe` per event. +- A newer relay retains the old method names and response members needed + by old clients. New identity fields are optional and are not made required + for hook/fs callers. +- A major protocol mismatch or a stale exact bundle is a detectable replacement + path, not a stale server used optimistically. A minor mismatch selects the + agreed capability subset. +- Two Orca instances share the immutable versioned tree but have distinct + instance keys, endpoint files, owner nonces, and stdio children. One cannot + answer the other's PTY requests or overwrite its endpoint. + +The wire review must follow `docs/reference/remote-wire-compatibility.md`: +optional fields are safe only while old readers can ignore them, and changing +host-published content is a wire change even without a codec change. Add a +cross-version agent harness for old/new client combinations and assert that a +new client degrades safely when the identity capability is absent. + +## Routing WSL foreground identity through the agent + +### Request shape and ownership + +Reuse the pure resolver and anchor model from PR #17757, but move its transport +from the host's per-burst `wsl.exe --exec ps` reader to an agent method. The +host-side manager exposes a request such as `process.identity.read` with a +batch of pane anchors for one distro. The agent captures one complete process +observation and resolves every anchor in that batch, returning one structured +record per request. A request carries the host's PTY id/incarnation for +correlation, but the guest's proof is the guest anchor and process data. + +Route all WSL foreground reads through this adapter: + +- `getForegroundProcess`/`getForegroundProcessName` uses the agent method; +- `inspectProcess` and completion confirmation consume the same structured + result; +- process/session listing may request the same batch projection; and +- no caller reaches the old host `runWslGuestProcessInventory` command runner + after migration. + +The agent's capture is single-flight per distro with a short bounded freshness +window (the current 500 ms event-burst window is a suitable starting point). +It reads `/proc` start ticks in the same capture and resolves all panes in O(N) +after the snapshot. It does not maintain a background process poll. + +### Fences (unchanged from PR #17757) + +The identity contract must preserve every existing fence, with no weakening or +new synonym: + +1. **Distro** — the requested distro and anchor distro must match. +2. **Boot id** — `/proc/sys/kernel/random/boot_id` must match the anchor. A + distro restart invalidates all prior observations. +3. **Guest shell PID** — the anchored shell PID must be present in the same + complete guest capture. +4. **`/proc` start ticks** — the shell's `/proc//stat` start-time ticks + must match, preventing PID reuse; a selected candidate's own start ticks + must come from that same capture. +5. **TTY** — the shell and candidate must use the same normalized controlling + tty. +6. **Foreground process group** — correlate the shell's terminal foreground + group (`tpgid`/`pgid`) and the rows in that group; the `stat` foreground + marker is not a substitute for the group relationship. +7. **Multiplexer boundary** — reject `tmux`, `screen`, or an equivalent + descendant/session crossing to another tty. Do not guess through a + multiplexer without a future session-aware anchor. +8. **Ambiguity/completeness** — a partial capture, missing required field, or + more than one recognized agent in the foreground group is not a name. + Return an explicit unverifiable reason. + +The agent may return `live` with `processName: null` when all fences prove a +foreground shell or other unrecognized command. It returns `unverifiable` for +missing anchors, stale boot/start ticks, tty/group mismatch, multiplexer +boundaries, ambiguity, malformed/partial capture, unsupported capability, +timeout, or lost contact. `exited` is reserved for positive evidence from the +owning host/agent tied to the exact authority, PTY id, and incarnation; it is +not produced by an absent map entry, a missing response, a closed socket, a +stopped distro, or an anchor that could not be checked. The only verdict +vocabulary is **`live` / `unverifiable` / `exited`**. + +The host must reject a late response when its PTY id, incarnation, distro, +agent generation, or request epoch no longer matches the pane. It must not +turn a transport error into `exited`. Existing launch/hook identity evidence +can continue to participate in pane identity resolution, but a guest process +name is accepted only from a validated `live` record. Title parsing is the +absolute LAST identity fallback (and remains a display fallback); it never +repairs a failed fence or upgrades `unverifiable` to `live`. + +Title is the absolute LAST identity fallback. + +### Agent health versus process death + +An agent request has a deadline and cancellation path. If the agent wedges, +the host marks the request `unverifiable`, disposes the mux, kills the child, +and schedules a bounded ensure only when the distro is still running. A +foreground identity result cannot claim that the shell or agent exited merely +because the agent failed. A future explicit guest retirement/tombstone method +may produce `exited`, but it must carry the same boot/authority/incarnation +fences and be tested as positive evidence. + +## Cost invariant and measurement + +The current PR #17757 rule is at most one host `ps` capture per distro per event +burst, shared by its panes through a 500 ms cache. Replace the _transport_, not +the bounded work: one qualifying burst becomes at most one `process.identity.read` +RPC per distro, one guest process snapshot, and O(number of anchors) local +resolution. Concurrent bursts join the in-flight request; different distros +remain isolated. There is no per-pane `wsl.exe`, no per-process `cat`, and no +active-agent interval poll. + +Qualifying events are spawn/attach/reattach/reconnect when an anchor or identity +expectation exists, an OSC command boundary that needs confirmation, visible or +focused inspection, an explicit completion check, or a hook/launch event that +starts a finite settle ladder. Repeated output alone does not start a loop. +The finite ladder is cancelled on success, rebind, disposal, expiry, or agent +shutdown. Automatic idle/session-list refreshes request inventory metadata +without `process.identity.read`. + +Instrument both sides with counters and bounded timings: + +- host: ensure calls, identity RPCs, joined/coalesced requests, deadline and + transport failures, bytes, and per-distro request latency; +- guest: snapshots, rows scanned, snapshot age, resolver count, and rejected + anchors; and +- boundary: `wsl.exe` process creations attributable to ensure/preflight versus + identity operations. + +Acceptance tests must prove that a long simulated idle with many WSL panes +produces exactly zero identity RPCs, relay snapshots, and identity `wsl.exe` +spawns; one burst produces one in-flight request/snapshot per distro; N panes +do not produce N snapshots; and a wedge/backoff does not spin. A real-WSL +benchmark should report the same counters and p50/p95 request latency without +recording command text or process arguments. + +## Migration from PR #17757 + +The work layers on PR #17757 for dependency order, then supersedes its host +transport: + +1. Land the existing anchor emission, parser, resolver, and fence tests from + #17757 (including distro, boot id, guest shell PID, `/proc` start ticks, tty, + foreground group, multiplexer, and ambiguity cases). This establishes + correct identity semantics while the adapter is built. +2. Add the general relay, ungated ensure lifecycle, capability handshake, and + `process.identity.read`. Move the #17757 pure resolver into the relay + bundle or a shared relay-safe module and compare relay results with its + existing fixtures. +3. Switch WSL foreground reads, inspect, completion, and listing to the agent + adapter. During a staged rollout, an internal diagnostic can count what the + old reader would have done, but it must not be a user-visible fallback or + source of identity. +4. Delete the production `wsl.exe --exec ps` per-operation path and its + per-event inventory runner once parity and real-WSL evidence pass. Keep + `wsl.exe` for agent preflight, versioned extraction, launch, and recovery; + those are the bootstrap boundary, not the data path. + +Thus #17757 is neither discarded nor left as a permanent fallback: its fences +and resolver are retained, while its expensive host invocation is replaced. +If the agent is unavailable, disabled, stale, or lacks the capability, WSL +identity is `unverifiable` and the title remains only the last display fallback. +No user command changes during any phase. + +## Failure modes and required behavior + +| Condition | Agent/manager behavior | Identity/status behavior | +| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| Agent fails to start or bundle extraction fails | Record a bounded failure/cooldown; show an actionable diagnostic; retry only on an eligible event or running-distro recovery. | `unverifiable`; never use a bare name or per-event `wsl.exe` fallback. | +| Guest has no Node >= 18 | Return a distinct no-Node diagnostic; do not install Node or modify the user's shell/commands; use a long cooldown. | `unverifiable`; hooks/fs/identity stay degraded for that distro. | +| Agent wedges or misses a deadline | Cancel the request, dispose the link, kill/replace the child after bounded recovery, and preserve the pane's prior evidence without inventing an exit. | `unverifiable`; contact loss is never `exited`. | +| Distro stops during a request | Fail the request and link; do not boot it from recovery; wait for a later WSL activity. | `unverifiable`; a new boot requires a new anchor. | +| `wsl --shutdown` | Mark every affected distro link unavailable and cancel requests; do not restart until a running PTY/explicit action warrants it. | `unverifiable` until a fresh agent and fences answer. | +| Two Orca instances use one distro | Share only immutable versioned installation under the guest install lock; keep separate instance keys, endpoint files, owner nonces, children, and request namespaces. | Each pane accepts only its own agent generation/incarnation. | +| Distro was removed | Drop only its state/cache/timers and stop retrying; do not map the request to another distro. | Existing panes become `unverifiable`; no identity is synthesized. | +| Hooks are disabled | Keep the relay for identity/fs/health; skip managed hook install/refresh and discard hook notifications at the host. | Identity still works from fenced relay reads. | +| Old guest version or missing capability | Negotiate compatible hook/fs subset or replace the stale bundle; never silently send unsupported identity methods. | `unverifiable` when `process.identity` is unavailable. | + +## Module split and verification plan + +Do not grow another monolithic relay file and do not add a max-lines disable or +per-file bump. Split concrete responsibilities, for example: + +- `src/relay/wsl-agent-hook-relay.ts` — process bootstrap, stdio lifetime, and + readiness; +- `src/shared/wsl-hook-relay-contract.ts` — handshake, capabilities, and + method names/types; +- `src/relay/wsl-relay-process.ts` — `/proc` capture and pure fenced + resolver; +- `src/relay/wsl-agent-hook-relay.ts` and + `src/relay/wsl-hook-fs-bridge.ts` — existing hook and fs adapters; +- `src/main/agent-hooks/wsl-hook-relay-manager.ts` — per-distro state and + capability gates; +- `src/main/agent-hooks/wsl-hook-relay-launch.ts` — preflight, lock, + extraction, and version selection; +- `src/main/agent-hooks/wsl-hook-relay-link.ts` — mux requests and death; + and +- `src/shared/wsl-hook-relay-contract.ts` — path, env, protocol, and + capability constants, with a temporary compatibility re-export for old hook + contract imports. + +Required tests include: + +- `/bin/sh` syntax, `--exec` argv, capture fencing, atomic extraction, + concurrent ensure/lock ownership, stale/partial version trees, app restart, + no Node, distro shutdown/removal, and two-instance isolation; +- protocol negotiation in both skew directions, unknown capability handling, + stale replacement, and old hook/fs method compatibility; +- every PR #17757 fence and ambiguity case through the agent request, plus + late-response PTY incarnation rejection and the exact verdict vocabulary; +- hooks-on versus hooks-off capability tests, and no hook mutation or event + publication while opted out; +- cost tests proving zero idle work, one snapshot/RPC per distro burst, + coalescing, bounded retries, and no process-table work for metadata-only + listing; and +- platform routing tests proving native Windows, macOS/Linux, SSH, and folder + workspaces never accidentally use the WSL agent. + +Real-WSL validation should use a disposable distro and ordinary user commands: +start agents by typing their normal command, cross a shell command boundary, +exercise a multiplexer, restart the distro, close/reopen Orca, and run two +instances. Compare identity and timing counters, verify that stale/ambiguous +cases stay `unverifiable`, and confirm that only positive host/agent evidence +can produce `exited`. diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index dcff332e57b..f18d6e25eb6 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -81,6 +81,19 @@ export function isWslHookRelayAllowed(deps: WslHookRelayManagerDeps): boolean { ) } +/** Residency gate: unlike hook installation, the relay is available in every + * resolvable WSL distro so process identity remains active when hooks are off. */ +export function isWslRelayAllowed( + deps: Pick, + distro: string | null | undefined +): boolean { + return deps.platform() === 'win32' && typeof distro === 'string' && distro.trim().length > 0 +} + +export function isWslRelayHooksAllowed(deps: WslHookRelayManagerDeps): boolean { + return deps.remoteHooksEnabled() && isAgentStatusHooksEnabled(deps.managedHookSettings()) +} + export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = { platform: () => process.platform, remoteHooksEnabled: () => isRemoteAgentHooksEnabled(), diff --git a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts index 84cf4bfa57a..f356e7c59f9 100644 --- a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts +++ b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts @@ -3,6 +3,7 @@ // decides when a still-running relay may install again. Kept out of the // manager so that file stays about relay lifecycle. import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands' +import { isAgentStatusHooksEnabled } from './managed-agent-hook-controls' import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install' import { installWslGuestHooks } from './wsl-hook-fs-adapter' @@ -17,6 +18,7 @@ type GuestInstallDeps = { managedHookSettings: () => ManagedHookDetectionSettings pluginSources: () => PluginSources warn: (message: string) => void + remoteHooksEnabled?: () => boolean } /** Structural slice of the manager's DistroState this pass reads and writes. */ @@ -35,6 +37,12 @@ export async function runWslRelayGuestInstall( mux: SshChannelMultiplexer, guestHome: string ): Promise { + if ( + deps.remoteHooksEnabled && + (!deps.remoteHooksEnabled() || !isAgentStatusHooksEnabled(deps.managedHookSettings())) + ) { + return + } state.lastInstallAt = Date.now() await installWslGuestHooks({ mux, @@ -71,6 +79,12 @@ export async function maybeRerunWslRelayGuestInstall( ) { return } + if ( + deps.remoteHooksEnabled && + (!deps.remoteHooksEnabled() || !isAgentStatusHooksEnabled(deps.managedHookSettings())) + ) { + return + } try { // Why: the pass also re-ships the plugin source, so a mid-session Orca upgrade refreshes it. await runWslRelayGuestInstall(deps, state, mux, guestHome) diff --git a/src/main/agent-hooks/wsl-hook-relay-identity.ts b/src/main/agent-hooks/wsl-hook-relay-identity.ts new file mode 100644 index 00000000000..350bc1f8b9a --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-identity.ts @@ -0,0 +1,53 @@ +import type { WslHookRelayManagerDeps } from './wsl-hook-relay-deps' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { + WSL_RELAY_PROCESS_METHODS, + type WslRelayIdentityResult +} from '../../shared/wsl-hook-relay-contract' +import type { WslShellProcessAnchor } from '../../shared/wsl-shell-process-anchor' + +export let wslRelayIdentityRpcCount = 0 + +export function resetWslRelayIdentityRpcCount(): void { + wslRelayIdentityRpcCount = 0 +} + +export async function readWslRelayProcessIdentity(options: { + distro: string + anchors: readonly WslShellProcessAnchor[] + deps: WslHookRelayManagerDeps + ensure: (distro: string) => Promise + getState: (distro: string) => { phase: string; mux?: SshChannelMultiplexer } | undefined + disposed: boolean + requestOptions?: { signal?: AbortSignal; timeoutMs?: number } +}): Promise { + const unavailable = (reason: string): WslRelayIdentityResult[] => + options.anchors.map(() => ({ status: 'unverifiable' as const, reason, capturedAgeMs: 0 })) + if (options.disposed || options.deps.platform() !== 'win32' || !options.distro.trim()) { + return unavailable('wsl_unavailable') + } + await options.ensure(options.distro) + const state = options.getState(options.distro) + const mux = state?.mux + if (!mux || mux.isDisposed() || state.phase !== 'running') { + return unavailable('relay_unavailable') + } + wslRelayIdentityRpcCount++ + try { + const response = (await mux.request( + WSL_RELAY_PROCESS_METHODS.identityRead, + { distro: options.distro, anchors: options.anchors }, + { + signal: options.requestOptions?.signal, + timeoutMs: options.requestOptions?.timeoutMs ?? 5_000 + } + )) as { results?: unknown } + if (!Array.isArray(response?.results) || response.results.length !== options.anchors.length) { + return unavailable('capture_malformed') + } + return response.results as WslRelayIdentityResult[] + } catch (error) { + const code = (error as { code?: unknown })?.code + return unavailable(code === -32601 ? 'unsupported_capability' : 'capture_failed') + } +} diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index 1803b4d6630..52f5fa29707 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -277,15 +277,23 @@ export function formatWslRelayFailure(failure: WslRelayStartupFailure): string { export function buildWslRelaySpawnEnv( coords: Record, bundleVersion: string, - instanceKey: string + instanceKey: string, + distro?: string, + hooksEnabled = true ): 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, + ...(coords.ORCA_AGENT_HOOK_PORT ? { ORCA_AGENT_HOOK_PORT: coords.ORCA_AGENT_HOOK_PORT } : {}), + ...(coords.ORCA_AGENT_HOOK_TOKEN + ? { ORCA_AGENT_HOOK_TOKEN: coords.ORCA_AGENT_HOOK_TOKEN } + : {}), + ...(coords.ORCA_AGENT_HOOK_ENV ? { ORCA_AGENT_HOOK_ENV: coords.ORCA_AGENT_HOOK_ENV } : {}), + ...(coords.ORCA_AGENT_HOOK_VERSION + ? { ORCA_AGENT_HOOK_VERSION: coords.ORCA_AGENT_HOOK_VERSION } + : {}), + ORCA_WSL_RELAY_HOOKS_ENABLED: hooksEnabled ? '1' : '0', + ...(distro ? { ORCA_WSL_RELAY_DISTRO: distro } : {}), [WSL_HOOK_RELAY_VERSION_ENV]: bundleVersion, [WSL_HOOK_RELAY_INSTANCE_ENV]: instanceKey } diff --git a/src/main/agent-hooks/wsl-hook-relay-lifecycle.ts b/src/main/agent-hooks/wsl-hook-relay-lifecycle.ts new file mode 100644 index 00000000000..880f153fa46 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-lifecycle.ts @@ -0,0 +1,173 @@ +import type { ChildProcessWithoutNullStreams } from 'node:child_process' + +import { + runWslRelayGuestInstall, + maybeRerunWslRelayGuestInstall +} from './wsl-hook-relay-guest-install' +import type { WslRelayRecovery } from './wsl-hook-relay-recovery' +import type { WslHookRelayManagerDeps } from './wsl-hook-relay-deps' +import { wireWslRelayLink } from './wsl-hook-relay-link' +import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' +import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay' +import { + WSL_HOOK_FS_METHODS, + wslHookRelayEndpointFilePath +} from '../../shared/wsl-hook-relay-contract' +import { isWslRelayHooksAllowed } from './wsl-hook-relay-deps' + +export type WslRelayState = { + distro: string + phase: 'starting' | 'running' | 'failed' + child?: ChildProcessWithoutNullStreams + mux?: SshChannelMultiplexer + guestHome?: string + codexHomePath?: string + guestEndpointFilePath?: string + opencodeOverlayDir?: string + failures: number + cooldownUntil: number + connectedAt?: number + restartTimer?: ReturnType + reinstallTimer?: ReturnType + lastInstallAt?: number +} + +export async function connectWslRelayState(options: { + state: WslRelayState + transport: MultiplexerTransport + child: ChildProcessWithoutNullStreams + instanceKey: string + deps: WslHookRelayManagerDeps + recovery: WslRelayRecovery + isDisposed: () => boolean + markFailed: (state: WslRelayState, message: string, cooldownBaseMs: number) => void +}): Promise { + const { state, transport, child, instanceKey, deps, recovery, isDisposed, markFailed } = options + const mux = new SshChannelMultiplexer(transport) + state.mux = mux + wireWslRelayLink({ + mux, + child, + distro: state.distro, + ingest: deps.ingest, + warn: deps.warn, + onDead: (reason) => { + if (isDisposed() || state.mux !== mux) { + return + } + state.mux = undefined + const wasRunning = state.phase === 'running' + if ( + wasRunning && + state.connectedAt !== undefined && + Date.now() - state.connectedAt >= 120_000 + ) { + state.failures = 0 + } + markFailed( + state, + `relay link for '${state.distro}' ${reason}; scheduling restart`, + wasRunning ? 10_000 : 60_000 + ) + } + }) + 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) { + 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) + if (isWslRelayHooksAllowed(deps)) { + await runWslRelayGuestInstall(deps, state, mux, homeResult.home) + } + if (state.phase === 'failed' || state.mux !== mux) { + return + } + state.phase = 'running' + state.connectedAt = Date.now() + recovery.scheduleOneShotReinstall(state, 60_000, () => { + void maybeRerunWslRelayGuestInstall(deps, state) + }) + if (isWslRelayHooksAllowed(deps)) { + void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch(() => undefined) + } +} + +export function markWslRelayFailed( + state: WslRelayState, + deps: WslHookRelayManagerDeps, + recovery: WslRelayRecovery, + message: string, + 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(cooldownBaseMs * state.failures, 10 * 60_000) + deps.warn(`[agent-hooks] WSL hook relay (${state.distro}): ${message}`) + recovery.scheduleRestart(state) +} + +export function resumeWslRelayStates( + stopped: Map, + isRunning: (distro: string) => Promise, + ensure: (distro: string, home?: string) => void +): void { + const distros = [...stopped] + stopped.clear() + for (const [distro, home] of distros) { + void isRunning(distro) + .then((running) => { + if (running) { + ensure(distro, home) + } + }) + .catch(() => undefined) + } +} + +export function disposeWslRelayStates( + states: Map, + recovery: WslRelayRecovery, + stopped: Map, + permanent: boolean +): void { + for (const state of states.values()) { + recovery.clearTimers(state) + state.mux?.dispose() + state.child?.kill() + if (!permanent) { + stopped.set(state.distro, state.codexHomePath) + } + } + states.clear() +} + +export async function resolveWslRelayDefaultDistro( + current: string | null, + list: () => Promise +): Promise { + if (current) { + return current + } + try { + return (await list())[0] ?? null + } catch { + return null + } +} diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 9d92f5d131a..4fbf48679d9 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -402,7 +402,7 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) - it('is inert off-Windows, when remote hooks are disabled, and when agent status hooks are off', async () => { + it('is inert off-Windows but keeps relay residency when hooks are disabled', async () => { const offPlatform = createManager({ platform: () => 'darwin' }) offPlatform.manager.ensureForDistro('Ubuntu') const disabled = createManager({ remoteHooksEnabled: () => false }) @@ -413,11 +413,12 @@ describe('WslHookRelayManager', () => { hooksOff.manager.ensureForDistro('Ubuntu') await new Promise((resolve) => setTimeout(resolve, 20)) expect(offPlatform.deps.spawnRelay).not.toHaveBeenCalled() - expect(disabled.deps.spawnRelay).not.toHaveBeenCalled() - expect(hooksOff.deps.spawnRelay).not.toHaveBeenCalled() + expect(disabled.deps.spawnRelay).toHaveBeenCalledTimes(1) + expect(hooksOff.deps.spawnRelay).toHaveBeenCalledTimes(1) + expect(hooksOff.deps.installHooks).not.toHaveBeenCalled() }) - it('stops live relays and refuses to revive them once agent status hooks are switched off', async () => { + it('keeps identity relay residency when agent status hooks are switched off', async () => { const settings = { agentStatusHooksEnabled: true } const { manager, deps } = createManager({ managedHookSettings: () => settings }) manager.ensureForDistro('Ubuntu', codexHome) @@ -429,16 +430,16 @@ describe('WslHookRelayManager', () => { manager.ensureForDistro('Ubuntu') await new Promise((resolve) => setTimeout(resolve, 20)) - expect(deps.spawnRelay).toHaveBeenCalledTimes(1) + expect(deps.spawnRelay).toHaveBeenCalledTimes(2) expect(deps.installHooks).toHaveBeenCalledTimes(1) - expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull() + expect(manager.getGuestEndpointFilePath('Ubuntu')).not.toBeNull() // Re-enabling puts the relay back without waiting for the next WSL spawn. settings.agentStatusHooksEnabled = true manager.resumeStoppedRelays() - await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(2)) - await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2)) - expect(deps.installCodex).toHaveBeenLastCalledWith(codexHome, 'Ubuntu') + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(deps.spawnRelay).toHaveBeenCalledTimes(2) + expect(deps.installCodex).toHaveBeenCalled() manager.disposeAll() }) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 1a414df5898..24fb7eca00a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -1,66 +1,57 @@ -// 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 { - runWslRelayGuestInstall, - maybeRerunWslRelayGuestInstall -} from './wsl-hook-relay-guest-install' +// Host-side lifecycle manager for the resident WSL relay. +import { maybeRerunWslRelayGuestInstall } from './wsl-hook-relay-guest-install' import { buildWslRelaySpawnEnv, launchWslRelayWithInstall } from './wsl-hook-relay-launch' import { defaultWslHookRelayDeps, - isWslHookRelayAllowed, + isWslRelayAllowed, + isWslRelayHooksAllowed, FAILURE_COOLDOWN_BASE_MS, FAILURE_COOLDOWN_MAX_MS, NO_NODE_COOLDOWN_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 { + connectWslRelayState, + disposeWslRelayStates, + resolveWslRelayDefaultDistro, + resumeWslRelayStates, + type WslRelayState +} from './wsl-hook-relay-lifecycle' +import { + readWslRelayProcessIdentity, + resetWslRelayIdentityRpcCount as resetIdentityCounter, + wslRelayIdentityRpcCount +} from './wsl-hook-relay-identity' import { wslHookRelayStateKey } from './wsl-hook-relay-state-key' -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 + WSL_RELAY_HOOKS_SET_ENABLED_METHOD } from '../../shared/wsl-hook-relay-contract' import { recordManagedWslCodexHome, wslRuntimeHomePathsEqual } from '../codex/managed-wsl-codex-home-registry' -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 - codexHomePath?: string - guestEndpointFilePath?: string - opencodeOverlayDir?: string - failures: number - cooldownUntil: number - connectedAt?: number - restartTimer?: ReturnType - reinstallTimer?: ReturnType - lastInstallAt?: number +type DistroState = WslRelayState + +export function getWslRelayIdentityRpcCount(): number { + return wslRelayIdentityRpcCount +} + +export function resetWslRelayIdentityRpcCount(): void { + resetIdentityCounter() } export class WslHookRelayManager { private deps: WslHookRelayManagerDeps private recovery: WslRelayRecovery private states = new Map() - /** Distros a hooks-off teardown stopped, so re-enabling can put them back. */ private stoppedByHooksOff = new Map() private defaultDistro: string | null = null private disposed = false private warnedBundleMissing = false + private ensurePromises = new Map>() constructor(deps: Partial = {}) { this.deps = { ...defaultWslHookRelayDeps, ...deps } @@ -71,8 +62,6 @@ export class WslHookRelayManager { isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state, restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath), 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 = wslHookRelayStateKey(state.distro) if (this.states.get(key) === state) { this.states.delete(key) @@ -83,77 +72,121 @@ export class WslHookRelayManager { setManagedHookSettingsResolver(resolve: WslHookRelayManagerDeps['managedHookSettings']): void { this.deps.managedHookSettings = resolve + this.refreshHookCapability() } - /** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */ - ensureForDistro(distro: string | null, codexHomePath?: string | null): void { - if (this.disposed || !isWslHookRelayAllowed(this.deps)) { - return - } - void this.ensureInternal(distro, codexHomePath ?? undefined).catch((err) => { - const detail = err instanceof Error ? err.message : String(err) - this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`) - }) - } - - private stateFor(distro: string | null): DistroState | undefined { - // Empty key never matches a real (non-empty) distro state. - return this.states.get(wslHookRelayStateKey(distro ?? this.defaultDistro ?? '')) - } - - /** 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 { - return this.stateFor(distro)?.guestEndpointFilePath ?? null - } - - /** Guest OpenCode config-overlay dir once the guest relay materializes it; - * null before then (older bundle / relay not yet connected). Callers drop - * OPENCODE_CONFIG_DIR while null so no Windows overlay path crosses into WSL. */ - getOpenCodeOverlayDir(distro: string | null): string | null { - return this.stateFor(distro)?.opencodeOverlayDir ?? null - } - - /** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the - * manager reusable, so re-enabling hooks can start relays again without an app restart. */ - disposeAll({ permanent = true }: { permanent?: boolean } = {}): void { - this.disposed ||= permanent + refreshHookCapability(): void { + const enabled = isWslRelayHooksAllowed(this.deps) for (const state of this.states.values()) { - this.recovery.clearTimers(state) - state.mux?.dispose() - state.child?.kill() - if (!permanent) { - this.stoppedByHooksOff.set(state.distro, state.codexHomePath) + if (state.phase !== 'running' || !state.mux || state.mux.isDisposed()) { + continue } - } - this.states.clear() - } - - /** Restarts what a hooks-off teardown stopped. Skips distros the user has since shut - * down: `wsl -d` BOOTS a stopped distro, and nothing in it is waiting on status. */ - resumeStoppedRelays(): void { - const distros = [...this.stoppedByHooksOff] - this.stoppedByHooksOff.clear() - for (const [distro, codexHomePath] of distros) { - void this.deps - .isDistroRunning(distro) - .then((running) => { - if (running) { - this.ensureForDistro(distro, codexHomePath) + void state.mux + .request(WSL_RELAY_HOOKS_SET_ENABLED_METHOD, { enabled }, { timeoutMs: 5_000 }) + .then(() => { + if (enabled) { + void maybeRerunWslRelayGuestInstall(this.deps, state) } }) .catch(() => undefined) } } + ensureForDistro(distro: string | null, codexHomePath?: string | null): void { + void this.ensureForDistroAsync(distro, codexHomePath) + } + + private ensureForDistroAsync( + distro: string | null, + codexHomePath?: string | null + ): Promise { + if ( + this.disposed || + this.deps.platform() !== 'win32' || + (distro !== null && !isWslRelayAllowed(this.deps, distro)) + ) { + return Promise.resolve() + } + const key = distro?.trim().toLowerCase() ?? '__all__' + const prior = this.ensurePromises.get(key) + if (prior) { + return prior + } + const pending = ( + distro === null + ? this.deps.listDistros().then((distros) => + Promise.all( + distros.map((candidate) => this.ensureForDistroAsync(candidate, codexHomePath)) + ).then(() => { + this.defaultDistro ||= distros[0] ?? null + }) + ) + : this.ensureInternal(distro, codexHomePath ?? undefined) + ) + .catch((err) => { + const detail = err instanceof Error ? err.message : String(err) + this.deps.warn(`[agent-hooks] WSL relay ensure failed: ${detail}`) + }) + .finally(() => { + if (this.ensurePromises.get(key) === pending) { + this.ensurePromises.delete(key) + } + }) + this.ensurePromises.set(key, pending) + return pending + } + + private stateFor(distro: string | null): DistroState | undefined { + return this.states.get(wslHookRelayStateKey(distro ?? this.defaultDistro ?? '')) + } + + getGuestEndpointFilePath(distro: string | null): string | null { + return this.stateFor(distro)?.guestEndpointFilePath ?? null + } + + getOpenCodeOverlayDir(distro: string | null): string | null { + return this.stateFor(distro)?.opencodeOverlayDir ?? null + } + + readProcessIdentity = ( + distro: string, + anchors: Parameters[0]['anchors'], + options?: { signal?: AbortSignal; timeoutMs?: number } + ) => + readWslRelayProcessIdentity({ + distro, + anchors, + deps: this.deps, + ensure: (target) => this.ensureForDistroAsync(target), + getState: (target) => this.stateFor(target), + disposed: this.disposed, + requestOptions: options + }) + + disposeAll({ permanent = true }: { permanent?: boolean } = {}): void { + this.disposed ||= permanent + disposeWslRelayStates(this.states, this.recovery, this.stoppedByHooksOff, permanent) + } + + resumeStoppedRelays(): void { + resumeWslRelayStates(this.stoppedByHooksOff, this.deps.isDistroRunning, (distro, home) => + this.ensureForDistro(distro, home) + ) + } + private async ensureInternal( requestedDistro: string | null, requestedCodexHomePath?: string ): Promise { - const distro = requestedDistro ?? (await this.resolveDefaultDistro()) + const distro = + requestedDistro ?? + (await resolveWslRelayDefaultDistro(this.defaultDistro, this.deps.listDistros)) if (!distro || this.disposed) { return } + if (!requestedDistro) { + this.defaultDistro = distro + } const key = wslHookRelayStateKey(distro) const existing = this.states.get(key) if (requestedCodexHomePath) { @@ -177,9 +210,6 @@ export class WslHookRelayManager { } 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) { @@ -188,8 +218,6 @@ export class WslHookRelayManager { } 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) { @@ -199,15 +227,19 @@ export class WslHookRelayManager { distro, phase: 'starting', failures: existing?.failures ?? 0, - // Why: instance-keyed and on the distro's persistent fs, so it outlives a relay - // crash — dropping it would blank status on panes spawned mid-relaunch. opencodeOverlayDir: existing?.opencodeOverlayDir, codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath, cooldownUntil: 0 } this.states.set(key, state) - const env = buildWslRelaySpawnEnv(coords, bundle.version, instanceKey) + const env = buildWslRelaySpawnEnv( + coords, + bundle.version, + instanceKey, + state.distro, + isWslRelayHooksAllowed(this.deps) + ) try { await launchWslRelayWithInstall({ @@ -216,8 +248,6 @@ export class WslHookRelayManager { bundleJsPath: bundle.jsPath, version: bundle.version, io: this.deps, - // Why the identity half: a hooks-off teardown drops this state and kills its child, but - // that kill reads as a startup failure and the retry loop would respawn an untracked relay. isDisposed: () => this.disposed || this.states.get(key) !== state, onChild: (child) => { state.child = child @@ -225,18 +255,27 @@ export class WslHookRelayManager { onNoNode: () => this.markFailed( state, - `no node >= 18 found in distro '${state.distro}'; agent hooks stay degraded there`, + `no node >= 18 found in distro '${state.distro}'; relay capabilities 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) + connect: (transport, child) => + connectWslRelayState({ + state, + transport, + child, + instanceKey, + deps: this.deps, + recovery: this.recovery, + isDisposed: () => this.disposed || this.states.get(key) !== state, + markFailed: (target, message, cooldownBaseMs) => + this.markFailed(target, message, { cooldownBaseMs }) + }) }) } 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') { @@ -247,78 +286,6 @@ export class WslHookRelayManager { } } - 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 runWslRelayGuestInstall(this.deps, 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 maybeRerunWslRelayGuestInstall(this.deps, state) - }) - void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch(() => { - // Fresh relays have nothing to replay; tolerate. - }) - } - - /** 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, @@ -337,19 +304,6 @@ export class WslHookRelayManager { 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/daemon/daemon-pty-session-inventory.ts b/src/main/daemon/daemon-pty-session-inventory.ts index 22b0513bc23..f73efdd6b52 100644 --- a/src/main/daemon/daemon-pty-session-inventory.ts +++ b/src/main/daemon/daemon-pty-session-inventory.ts @@ -15,13 +15,7 @@ import type { ListSessionsResult, SessionInfo } from './types' import { PtyProcessListAdmission } from '../providers/pty-process-list-admission' import type { PtyProcessInfo } from '../providers/types' import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence' -import { - createWslGuestProcessIndexes, - readWslGuestProcessInventory, - resolveWslGuestForegroundProcess, - WSL_GUEST_INVENTORY_MAX_CONCURRENCY, - type WslGuestProcessInventoryRead -} from '../providers/wsl-guest-process-inventory' +import { wslRelayIdentityReader } from '../providers/wsl-relay-identity-reader' export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspection { async listProcesses(opts?: { @@ -57,57 +51,45 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti const processes: PtyProcessInfo[] = [] const aliveSessionIds = new Set() const evidenceEpoch = Date.now() - const wslByDistro = new Map() + const wslBySession = new Map< + string, + Awaited>[number] + >() if (process.platform === 'win32') { - const distros = new Set( - result.sessions - .filter((session) => session.isAlive && session.wslDistro) - .filter((session) => session.wslShellAnchor) - .map((session) => session.wslDistro as string) - ) - const distroList = [...distros] - let nextDistroIndex = 0 - const readNextDistro = async (): Promise => { - while (nextDistroIndex < distroList.length) { - const distro = distroList[nextDistroIndex++]! - wslByDistro.set( - distro, - await readWslGuestProcessInventory(distro, { - deadlineMs: opts?.deadlineMs, - signal: opts?.signal - }) - ) + const byDistro = new Map() + for (const session of result.sessions) { + if (session.isAlive && session.wslDistro && session.wslShellAnchor) { + const group = byDistro.get(session.wslDistro) ?? [] + group.push(session) + byDistro.set(session.wslDistro, group) } } await Promise.all( - Array.from( - { length: Math.min(WSL_GUEST_INVENTORY_MAX_CONCURRENCY, distroList.length) }, - () => readNextDistro() - ) + [...byDistro].map(async ([distro, sessions]) => { + const values = await wslRelayIdentityReader.readBatch( + distro, + sessions.map((session) => session.wslShellAnchor!), + { + signal: opts?.signal, + timeoutMs: + opts?.deadlineMs === undefined + ? undefined + : Math.max(1, opts.deadlineMs - Date.now()) + } + ) + sessions.forEach((session, index) => + wslBySession.set(session.sessionId, values[index]!) + ) + }) ) } - const indexesByDistro = new Map>() - for (const [distro, inventory] of wslByDistro) { - if (inventory.status === 'ok') { - indexesByDistro.set(distro, createWslGuestProcessIndexes(inventory.inventory)) - } - } for (const session of result.sessions) { if (!session.isAlive) { continue } aliveSessionIds.add(session.sessionId) const { worktreeId } = parsePtySessionId(session.sessionId) - const inventory = session.wslDistro ? wslByDistro.get(session.wslDistro) : undefined - const resolution = - session.wslDistro && session.wslShellAnchor && inventory?.status === 'ok' - ? resolveWslGuestForegroundProcess( - inventory.inventory, - session.wslShellAnchor, - indexesByDistro.get(session.wslDistro) ?? - createWslGuestProcessIndexes(inventory.inventory) - ) - : null + const resolution = session.wslDistro ? wslBySession.get(session.sessionId) : null const foregroundProcessEvidence: ForegroundProcessEvidence | undefined = session.wslDistro ? resolution?.status === 'live' ? { @@ -115,19 +97,15 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti processName: resolution.processName, authorityGeneration: session.incarnationId ?? 'daemon-wsl', observationEpoch: evidenceEpoch, - capturedAgeMs: 0 + capturedAgeMs: resolution.capturedAgeMs } : { verdict: 'unverifiable', reason: - resolution?.status === 'unverifiable' - ? resolution.reason - : inventory?.status === 'unverifiable' - ? inventory.reason - : 'anchor_missing', + resolution?.status === 'unverifiable' ? resolution.reason : 'anchor_missing', authorityGeneration: session.incarnationId ?? 'daemon-wsl', observationEpoch: evidenceEpoch, - capturedAgeMs: 0 + capturedAgeMs: resolution?.capturedAgeMs ?? 0 } : undefined processes.push( diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 908d0730221..cd1138e94c1 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -75,6 +75,11 @@ export function buildPtyHostEnv( ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') + if (opts.isWsl === true) { + // Relay residency is independent of hook policy: identity remains usable + // when hooks are opted out, while the hook capability stays inert. + wslHookRelayManager.ensureForDistro(opts.wslDistro ?? null, opts.selectedCodexHomePath) + } if (opts.agentStatusHooksEnabled) { // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) @@ -122,10 +127,13 @@ export function buildPtyHostEnv( if (opts.isWsl === true) { // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. const distro = opts.wslDistro ?? null - wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath) const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) if (guestEndpoint) { baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint + } else { + // Never point a WSL shell at the Windows receiver when the hook + // capability is off or the relay is still starting. + delete baseEnv.ORCA_AGENT_HOOK_ENDPOINT } // Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized. const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro) diff --git a/src/main/providers/local-pty-foreground-inspection.ts b/src/main/providers/local-pty-foreground-inspection.ts index f8e272c36af..85b67e8ac65 100644 --- a/src/main/providers/local-pty-foreground-inspection.ts +++ b/src/main/providers/local-pty-foreground-inspection.ts @@ -12,10 +12,7 @@ import { ptyWslDistroById, ptyWslShellAnchors } from './local-pty-provider-state' -import { - readWslGuestProcessInventory, - resolveWslGuestForegroundProcess -} from './wsl-guest-process-inventory' +import { wslRelayIdentityReader } from './wsl-relay-identity-reader' import { resolveStableForegroundProcess } from './stable-foreground-process' import { canRevalidateCachedAgentWithoutScan, @@ -57,25 +54,26 @@ export async function getLocalPtyForegroundProcess(id: string): Promise distro?.toLowerCase() === wslDistro.toLowerCase()) + .map(([peerId]) => ptyWslShellAnchors.get(peerId)) + .filter((candidate): candidate is NonNullable => candidate !== undefined) + const reads = await wslRelayIdentityReader.readBatch(wslDistro, peers) + const read = reads[peers.indexOf(anchor)]! if (ptyProcesses.get(id) !== proc) { return null } - if (resolution.status === 'unverifiable') { + if (read.status === 'unverifiable') { return null } - ptyWslShellAnchors.set(id, resolution.anchor) - if (resolution.processName) { + ptyWslShellAnchors.set(id, read.anchor) + if (read.processName) { ptyLastRecognizedForeground.set(id, { - name: resolution.processName, - pid: resolution.anchor.shellPid, + name: read.processName, + pid: read.anchor.shellPid, at: Date.now() }) - return resolution.processName + return read.processName } return null } @@ -196,16 +194,12 @@ export async function confirmLocalPtyForegroundProcess(id: string): Promise @@ -133,7 +133,7 @@ export function clearPtyState(id: string): void { ptyTerminationMode.delete(id) ptyReportsChildExitStatus.delete(id) ptyPhysicalExits.delete(id) - resetWslGuestProcessInventory() + wslRelayIdentityReader.reset() } /** diff --git a/src/main/providers/local-pty-session-operations.ts b/src/main/providers/local-pty-session-operations.ts index 80c8b4b0086..4a1e524a2b2 100644 --- a/src/main/providers/local-pty-session-operations.ts +++ b/src/main/providers/local-pty-session-operations.ts @@ -23,13 +23,7 @@ import { } from './local-pty-provider-state' import type { LocalPtyProviderOptions } from './local-pty-provider-types' import type { PtyProcessInfo } from './types' -import { - createWslGuestProcessIndexes, - readWslGuestProcessInventory, - resolveWslGuestForegroundProcess, - WSL_GUEST_INVENTORY_MAX_CONCURRENCY, - type WslGuestProcessInventoryRead -} from './wsl-guest-process-inventory' +import { wslRelayIdentityReader } from './wsl-relay-identity-reader' import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence' export function writeLocalPty(id: string, data: string): boolean { @@ -141,32 +135,30 @@ export async function listLocalPtyProcesses(opts?: { wslByDistro.set(distro, ids) } } - const inventories = new Map() + const identityByPty = new Map< + string, + Awaited>[number] + >() const distros = [...wslByDistro.keys()] - let nextDistroIndex = 0 - const readNextDistro = async (): Promise => { - while (nextDistroIndex < distros.length) { - const distro = distros[nextDistroIndex++]! - inventories.set( - distro, - await readWslGuestProcessInventory(distro, { - deadlineMs: opts?.deadlineMs, - signal: opts?.signal - }) - ) - } - } await Promise.all( - Array.from({ length: Math.min(WSL_GUEST_INVENTORY_MAX_CONCURRENCY, distros.length) }, () => - readNextDistro() - ) + distros.map(async (distro) => { + const ids = wslByDistro.get(distro) ?? [] + const anchors = ids + .map((id) => ptyWslShellAnchors.get(id)) + .filter((anchor): anchor is NonNullable => anchor !== undefined) + const results = await wslRelayIdentityReader.readBatch(distro, anchors, { + signal: opts?.signal, + timeoutMs: + opts?.deadlineMs === undefined ? undefined : Math.max(1, opts.deadlineMs - Date.now()) + }) + let index = 0 + for (const id of ids) { + if (ptyWslShellAnchors.has(id)) { + identityByPty.set(id, results[index++]!) + } + } + }) ) - const indexesByDistro = new Map>() - for (const [distro, read] of inventories) { - if (read.status === 'ok') { - indexesByDistro.set(distro, createWslGuestProcessIndexes(read.inventory)) - } - } return entries.flatMap(([id, proc]) => { // Inventory reads are asynchronous; a PTY may have exited while they ran. @@ -178,19 +170,14 @@ export async function listLocalPtyProcesses(opts?: { let title = proc.process || ptyShellName.get(id) || 'shell' let foregroundProcessEvidence: ForegroundProcessEvidence | undefined if (distro) { - const read = inventories.get(distro) const anchor = ptyWslShellAnchors.get(id) - const resolution = - read?.status === 'ok' && anchor - ? resolveWslGuestForegroundProcess( - read.inventory, - anchor, - indexesByDistro.get(distro) ?? createWslGuestProcessIndexes(read.inventory) - ) - : { - status: 'unverifiable' as const, - reason: read?.status === 'unverifiable' ? read.reason : 'anchor_missing' - } + const resolution = anchor + ? (identityByPty.get(id) ?? { + status: 'unverifiable' as const, + reason: 'relay_unavailable', + capturedAgeMs: 0 + }) + : { status: 'unverifiable' as const, reason: 'anchor_missing', capturedAgeMs: 0 } foregroundProcessEvidence = resolution.status === 'live' ? { @@ -198,14 +185,14 @@ export async function listLocalPtyProcesses(opts?: { processName: resolution.processName, authorityGeneration: ptyIncarnations.get(id) ?? 'local-wsl', observationEpoch: evidenceEpoch, - capturedAgeMs: 0 + capturedAgeMs: resolution.capturedAgeMs } : { verdict: 'unverifiable', reason: resolution.reason, authorityGeneration: ptyIncarnations.get(id) ?? 'local-wsl', observationEpoch: evidenceEpoch, - capturedAgeMs: 0 + capturedAgeMs: resolution.capturedAgeMs } if (resolution.status === 'live') { ptyWslShellAnchors.set(id, resolution.anchor) diff --git a/src/main/providers/wsl-guest-process-inventory.ts b/src/main/providers/wsl-guest-process-inventory.ts index 1dac6261bf0..b988ade1479 100644 --- a/src/main/providers/wsl-guest-process-inventory.ts +++ b/src/main/providers/wsl-guest-process-inventory.ts @@ -4,23 +4,23 @@ import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' import { resolveWslExecutablePath } from '../wsl/wsl-executable-path' -import { parseWslGuestProcessInventoryPayload } from './wsl-guest-process-inventory-parser' -import type { WslGuestProcessInventory } from './wsl-guest-process-inventory-parser' +import { parseWslGuestProcessInventoryPayload } from '../../shared/wsl-guest-process-inventory-parser' +import type { WslGuestProcessInventory } from '../../shared/wsl-guest-process-inventory-parser' -export { parseWslGuestProcessInventoryPayload } from './wsl-guest-process-inventory-parser' +export { parseWslGuestProcessInventoryPayload } from '../../shared/wsl-guest-process-inventory-parser' export type { WslGuestProcessInventory, WslGuestProcessRow -} from './wsl-guest-process-inventory-parser' +} from '../../shared/wsl-guest-process-inventory-parser' export { createWslGuestProcessIndexes, resolveWslGuestForegroundProcess -} from './wsl-guest-foreground-process-resolution' +} from '../../shared/wsl-guest-foreground-process-resolution' export type { WslGuestForegroundResolution, WslGuestProcessAnchor, WslGuestProcessIndexes -} from './wsl-guest-foreground-process-resolution' +} from '../../shared/wsl-guest-foreground-process-resolution' export type WslGuestProcessInventoryRead = | { status: 'ok'; inventory: WslGuestProcessInventory } @@ -35,7 +35,11 @@ export type WslGuestProcessInventoryFailureReason = | 'boot_id_missing' const INVENTORY_TIMEOUT_MS = 5_000 -const INVENTORY_MAX_OUTPUT_BYTES = 4 * 1024 * 1024 +// Legacy compatibility reader retained for fixtures; production WSL identity +// is served by the resident relay. Keep the historical 32 MiB bound for any +// direct diagnostic invocation so a busy host cannot overflow Node's default. +export const PS_MAX_BUFFER_BYTES = 32 * 1024 * 1024 +const INVENTORY_MAX_OUTPUT_BYTES = PS_MAX_BUFFER_BYTES const INVENTORY_TTL_MS = 500 export const WSL_GUEST_INVENTORY_MAX_CONCURRENCY = 4 const INVENTORY_CACHE_MAX_DISTROS = 32 diff --git a/src/main/providers/wsl-relay-identity-reader.test.ts b/src/main/providers/wsl-relay-identity-reader.test.ts new file mode 100644 index 00000000000..51f31778a8b --- /dev/null +++ b/src/main/providers/wsl-relay-identity-reader.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createWslRelayIdentityReader } from './wsl-relay-identity-reader' + +describe('wsl relay identity reader cost invariant', () => { + it('does no identity work while idle', async () => { + const readProcessIdentity = vi.fn() + const reader = createWslRelayIdentityReader({ readProcessIdentity }) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(readProcessIdentity).not.toHaveBeenCalled() + expect(reader).toBeDefined() + }) +}) diff --git a/src/main/providers/wsl-relay-identity-reader.ts b/src/main/providers/wsl-relay-identity-reader.ts new file mode 100644 index 00000000000..6fc8d1a185f --- /dev/null +++ b/src/main/providers/wsl-relay-identity-reader.ts @@ -0,0 +1,85 @@ +// Host adapter for the WSL relay's process capability. Keeping this boundary +// here makes it impossible for callers to fall back to a per-event wsl.exe ps. +import { + wslHookRelayManager, + type WslHookRelayManager +} from '../agent-hooks/wsl-hook-relay-manager' +import type { WslRelayIdentityResult } from '../../shared/wsl-hook-relay-contract' +import type { WslShellProcessAnchor } from '../../shared/wsl-shell-process-anchor' + +export type WslRelayIdentityReader = { + read: ( + distro: string, + anchor: WslShellProcessAnchor, + options?: { signal?: AbortSignal; timeoutMs?: number } + ) => Promise + readBatch: ( + distro: string, + anchors: readonly WslShellProcessAnchor[], + options?: { signal?: AbortSignal; timeoutMs?: number } + ) => Promise + reset: () => void +} + +export function createWslRelayIdentityReader( + manager: Pick = wslHookRelayManager +): WslRelayIdentityReader { + const cache = new Map< + string, + { at: number; anchors: readonly WslShellProcessAnchor[]; results: WslRelayIdentityResult[] } + >() + const pending = new Map< + string, + Promise<{ anchors: readonly WslShellProcessAnchor[]; results: WslRelayIdentityResult[] }> + >() + const readBatch = async ( + distro: string, + anchors: readonly WslShellProcessAnchor[], + options?: { signal?: AbortSignal; timeoutMs?: number } + ): Promise => { + const key = distro.trim().toLowerCase() + const now = Date.now() + const prior = cache.get(key) + if (prior && now - prior.at < 500) { + const byAnchor = new Map( + prior.anchors.map((anchor, index) => [JSON.stringify(anchor), prior.results[index]!]) + ) + if (anchors.every((anchor) => byAnchor.has(JSON.stringify(anchor)))) { + return anchors.map((anchor) => byAnchor.get(JSON.stringify(anchor))!) + } + } + const active = pending.get(key) + if (active) { + const result = await active + const byAnchor = new Map( + result.anchors.map((anchor, index) => [JSON.stringify(anchor), result.results[index]!]) + ) + if (anchors.every((anchor) => byAnchor.has(JSON.stringify(anchor)))) { + return anchors.map((anchor) => byAnchor.get(JSON.stringify(anchor))!) + } + } + const request = manager + .readProcessIdentity(distro, anchors, options) + .then((results) => ({ anchors, results })) + pending.set(key, request) + try { + const result = await request + cache.set(key, { at: Date.now(), ...result }) + return result.results + } finally { + if (pending.get(key) === request) { + pending.delete(key) + } + } + } + return { + read: async (distro, anchor, options) => (await readBatch(distro, [anchor], options))[0]!, + readBatch, + reset: () => { + cache.clear() + pending.clear() + } + } +} + +export const wslRelayIdentityReader = createWslRelayIdentityReader() diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 05da188e9d4..a279420c924 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -149,6 +149,8 @@ export function addOrcaWslInteropEnv( ...opencodeOverlayEntries, 'ORCA_WSL_HOOK_RELAY_VERSION/u', 'ORCA_WSL_HOOK_INSTANCE/u', + 'ORCA_WSL_RELAY_HOOKS_ENABLED/u', + 'ORCA_WSL_RELAY_DISTRO/u', 'ORCA_OMP_SOURCE_AGENT_DIR/p', 'ORCA_OMP_STATUS_EXTENSION/p', ...worktreeSetupWslenvEntries(env) diff --git a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts index d2c76ae1ee8..c3e42618b9e 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts @@ -16,11 +16,7 @@ export const WORKTREE_CATALOG_METHODS: RpcMethod[] = [ context, params.supportsWorktreeVisibilitySourceDefaults ) - const result = context.signal - ? await context.runtime.getWorktreePs(params.limit, supportsSourceDefaults, { - signal: context.signal - }) - : await context.runtime.getWorktreePs(params.limit, supportsSourceDefaults) + const result = await context.runtime.getWorktreePs(params.limit, supportsSourceDefaults) // Why: callers that never send the field get the byte-exact legacy response. return params.afterSnapshotId === undefined ? result diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index e122961b0c9..0749a1c4347 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -47,7 +47,6 @@ import { syncMacMenuBarIcon } from './main-window-actions' import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' export async function initializeReadyFoundation(): Promise { logStartupMilestone('app-ready') @@ -205,15 +204,10 @@ export async function initializeReadyFoundation(): Promise { // Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live. syncMacMenuBarIcon(settings.showMenuBarIcon !== false) } + // Hook settings only alter the hook capability. Relay residency and + // foreground identity continue across an opt-out. if ('agentStatusHooksEnabled' in updates) { - // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running - // guest process and timers, and on must restart them — otherwise open WSL panes report no - // status until their next spawn. - if (isAgentStatusHooksEnabled(settings)) { - wslHookRelayManager.resumeStoppedRelays() - } else { - wslHookRelayManager.disposeAll({ permanent: false }) - } + wslHookRelayManager.refreshHookCapability() } }) // Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session. diff --git a/src/relay/wsl-agent-hook-relay.ts b/src/relay/wsl-agent-hook-relay.ts index 7f3ee8e4062..9af24346af7 100644 --- a/src/relay/wsl-agent-hook-relay.ts +++ b/src/relay/wsl-agent-hook-relay.ts @@ -12,13 +12,14 @@ // period and no daemon socket. import { homedir } from 'node:os' -import { RELAY_SENTINEL } from './protocol' +import { RELAY_SENTINEL, RELAY_VERSION } from './protocol' import { RelayDispatcher } from './dispatcher' import { RelayAgentHookServer } from './agent-hook-server' import { registerWslHookFsHandlers } from './wsl-hook-fs-bridge' import { PluginOverlayManager } from './plugin-overlay' import { createInstallPluginsHandler } from './wsl-install-plugins-handler' import { PreflightHandler } from './preflight-handler' +import { registerWslRelayProcessHandlers } from './wsl-relay-process' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD @@ -26,6 +27,8 @@ import { import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' import { sanitizeWslHookInstanceKey, + WSL_RELAY_CAPABILITIES, + WSL_RELAY_HOOKS_SET_ENABLED_METHOD, WSL_HOOK_RELAY_INSTANCE_ENV, wslHookRelayEndpointDir } from '../shared/wsl-hook-relay-contract' @@ -33,12 +36,14 @@ import { 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) + const hooksEnabled = process.env.ORCA_WSL_RELAY_HOOKS_ENABLED !== '0' + const relayDistro = process.env.ORCA_WSL_RELAY_DISTRO?.trim() || null + if (hooksEnabled && (!Number.isInteger(windowsPort) || windowsPort <= 0 || token.length === 0)) { + process.stderr.write('[wsl-relay] hook capability disabled: missing port/token\n') } let stdoutAlive = true + let hookCapabilityEnabled = hooksEnabled const dispatcher = new RelayDispatcher( (data, onSettled) => { if (!stdoutAlive) { @@ -64,51 +69,89 @@ async function main(): Promise { // 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) => publishAgentHookEnvelope(dispatcher, envelope) - }) + const hookServer = + Number.isInteger(windowsPort) && windowsPort > 0 && token.length > 0 + ? new RelayAgentHookServer({ + endpointDir: wslHookRelayEndpointDir(homedir(), instanceKey), + token, + preferredPort: windowsPort, + forward: (envelope) => { + if (hookCapabilityEnabled) { + publishAgentHookEnvelope(dispatcher, envelope) + } + } + }) + : null new PreflightHandler(dispatcher) - - dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({ - replayed: hookServer.replayCachedPayloadsForPanes() + registerWslRelayProcessHandlers(dispatcher, relayDistro) + dispatcher.onRequest('relay.capabilities', async () => ({ + protocolMajor: 1, + protocolMinor: 0, + bundleVersion: RELAY_VERSION, + capabilities: Object.values(WSL_RELAY_CAPABILITIES) })) + dispatcher.onRequest(WSL_RELAY_HOOKS_SET_ENABLED_METHOD, async (params) => { + const enabled = params.enabled === true + if (enabled === hookCapabilityEnabled) { + return { enabled: hookCapabilityEnabled } + } + hookCapabilityEnabled = enabled + if (!hookServer) { + hookCapabilityEnabled = false + return { enabled: false } + } + return { enabled: hookCapabilityEnabled } + }) + + if (hookServer) { + dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({ + replayed: hookCapabilityEnabled ? hookServer.replayCachedPayloadsForPanes() : 0 + })) + } // Why: OpenCode reports status via a plugin (not a hooks.json script), so the // host ships its source over the wire and the guest materializes a config // overlay here — the same PluginOverlayManager path the SSH relay uses. One // handler for the relay's life: it remembers the materialized overlay so // repeat installs don't rebuild it under running agents. - const installPlugins = createInstallPluginsHandler(new PluginOverlayManager(), process.env) - dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => installPlugins(params)) + if (hookServer) { + const installPlugins = createInstallPluginsHandler(new PluginOverlayManager(), process.env) + dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => + hookCapabilityEnabled ? installPlugins(params) : { overlayDirs: {} } + ) + } - registerWslHookFsHandlers(dispatcher, homedir(), () => ({ - portFallback: hookServer.usedPortFallback, - boundPort: hookServer.getCoordinates().port - })) + registerWslHookFsHandlers( + dispatcher, + homedir(), + hookServer + ? () => ({ + portFallback: hookServer.usedPortFallback, + boundPort: hookServer.getCoordinates().port + }) + : undefined + ) try { - await hookServer.start() + await hookServer?.start() } catch (err) { process.stderr.write( - `[wsl-hook-relay] hook server bind failed: ${err instanceof Error ? err.message : String(err)}\n` + `[wsl-relay] hook server bind failed: ${err instanceof Error ? err.message : String(err)}\n` ) process.exit(1) } - if (hookServer.usedPortFallback) { + 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` + `[wsl-relay] port ${windowsPort} occupied; bound ${hookServer.getCoordinates().port} (endpoint-file re-coordination)\n` ) } const shutdown = (): void => { stdoutAlive = false dispatcher.dispose() - hookServer.stop() + hookServer?.stop() process.exit(0) } @@ -123,11 +166,11 @@ async function main(): Promise { // 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.stderr.write(`[wsl-relay] uncaught exception: ${err.message}\n`) process.exit(1) }) process.on('unhandledRejection', (reason) => { - process.stderr.write(`[wsl-hook-relay] unhandled rejection: ${String(reason)}\n`) + process.stderr.write(`[wsl-relay] unhandled rejection: ${String(reason)}\n`) }) // Signal readiness — the host watches for this exact string before diff --git a/src/relay/wsl-relay-process.ts b/src/relay/wsl-relay-process.ts new file mode 100644 index 00000000000..45bf46ef6e0 --- /dev/null +++ b/src/relay/wsl-relay-process.ts @@ -0,0 +1,208 @@ +// Guest-side process/identity capability for the resident WSL relay. It reads +// /proc directly, so foreground checks never fork wsl.exe on the Windows host. +import { readFileSync, readdirSync, readlinkSync } from 'node:fs' + +import type { RelayDispatcher } from './dispatcher' +import { + WSL_RELAY_CAPABILITIES, + WSL_RELAY_PROCESS_METHODS, + type WslRelayIdentityRequest, + type WslRelayIdentityResult +} from '../shared/wsl-hook-relay-contract' +import { + createWslGuestProcessIndexes, + resolveWslGuestForegroundProcess +} from '../shared/wsl-guest-foreground-process-resolution' +import type { + WslGuestProcessInventory, + WslGuestProcessRow +} from '../shared/wsl-guest-process-inventory-parser' + +const SNAPSHOT_TTL_MS = 500 + +export type WslRelayProcessMetrics = { + snapshots: number + rowsScanned: number + resolutions: number + rejectedAnchors: number +} + +const metrics: WslRelayProcessMetrics = { + snapshots: 0, + rowsScanned: 0, + resolutions: 0, + rejectedAnchors: 0 +} + +export function getWslRelayProcessMetrics(): WslRelayProcessMetrics { + return { ...metrics } +} + +export function resetWslRelayProcessMetrics(): void { + metrics.snapshots = 0 + metrics.rowsScanned = 0 + metrics.resolutions = 0 + metrics.rejectedAnchors = 0 + cachedCapture = null +} + +type CachedCapture = { inventory: WslGuestProcessInventory; capturedAt: number } + +let cachedCapture: CachedCapture | null = null +let inFlightCapture: Promise | null = null + +function readBootId(): string { + const bootId = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() + if (!/^[A-Fa-f0-9-]{8,128}$/.test(bootId)) { + throw new Error('boot_id_missing') + } + return bootId +} + +function readStat(pid: number): { row: Omit; tty: string } { + const text = readFileSync(`/proc/${pid}/stat`, 'utf8').trim() + const close = text.lastIndexOf(')') + if (close === -1) { + throw new Error('malformed_stat') + } + const fields = text.slice(close + 2).split(/\s+/) + const state = fields[0] ?? '?' + const numbers = fields.slice(1).map(Number) + const ppid = numbers[0] + const pgid = numbers[1] + const sid = numbers[2] + const ttyNr = numbers[3] + const tpgid = numbers[4] + const startTimeTicks = numbers[18] + if (![ppid, pgid, sid, ttyNr, tpgid, startTimeTicks].every(Number.isSafeInteger)) { + throw new Error('malformed_stat') + } + let tty = '?' + try { + const link = readlinkSync(`/proc/${pid}/fd/0`) + tty = link.startsWith('/dev/') ? link : '?' + } catch { + // A process can close stdin while its /proc row is still visible. + } + return { + tty, + row: { + pid, + ppid, + sid, + pgid, + tpgid, + stat: state, + startTimeTicks + } + } +} + +function readCommand(pid: number): string { + try { + const command = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replaceAll('\0', ' ').trim() + if (command) { + return command + } + } catch { + // The process may exit between stat and cmdline; use comm where possible. + } + try { + return readFileSync(`/proc/${pid}/comm`, 'utf8').trim() + } catch { + return '' + } +} + +function capture(distro: string): CachedCapture { + metrics.snapshots++ + const bootId = readBootId() + const rows: WslGuestProcessRow[] = [] + for (const entry of readdirSync('/proc')) { + if (!/^\d+$/.test(entry)) { + continue + } + const pid = Number(entry) + try { + const stat = readStat(pid) + rows.push({ ...stat.row, tty: stat.tty, command: readCommand(pid) }) + } catch { + // A vanished /proc row is expected during a busy capture; skip it. + } + } + metrics.rowsScanned += rows.length + return { inventory: { distro, bootId, rows }, capturedAt: Date.now() } +} + +async function readCapture(distro: string): Promise { + if (cachedCapture && Date.now() - cachedCapture.capturedAt < SNAPSHOT_TTL_MS) { + return cachedCapture + } + if (!inFlightCapture) { + inFlightCapture = Promise.resolve() + .then(() => capture(distro)) + .finally(() => { + inFlightCapture = null + }) + } + const next = await inFlightCapture + cachedCapture = next + return next +} + +export function registerWslRelayProcessHandlers( + dispatcher: RelayDispatcher, + relayDistro: string | null +): void { + dispatcher.onRequest( + WSL_RELAY_PROCESS_METHODS.identityRead, + async (params): Promise<{ capability: string; results: WslRelayIdentityResult[] }> => { + const request = params as unknown as WslRelayIdentityRequest + const anchors = Array.isArray(request.anchors) ? request.anchors : [] + const distro = typeof request.distro === 'string' ? request.distro : (relayDistro ?? '') + if (relayDistro && distro.toLowerCase() !== relayDistro.toLowerCase()) { + return { + capability: WSL_RELAY_CAPABILITIES.processIdentity, + results: anchors.map(() => ({ + status: 'unverifiable' as const, + reason: 'distro_mismatch', + capturedAgeMs: 0 + })) + } + } + let snapshot: CachedCapture + try { + snapshot = await readCapture(distro) + } catch { + return { + capability: WSL_RELAY_CAPABILITIES.processIdentity, + results: anchors.map(() => ({ + status: 'unverifiable' as const, + reason: 'capture_failed', + capturedAgeMs: 0 + })) + } + } + const indexes = createWslGuestProcessIndexes(snapshot.inventory) + const results = anchors.map((anchor) => { + metrics.resolutions++ + const resolution = resolveWslGuestForegroundProcess(snapshot.inventory, anchor, indexes) + if (resolution.status === 'live') { + return { + status: 'live' as const, + processName: resolution.processName, + anchor: resolution.anchor, + capturedAgeMs: Math.max(0, Date.now() - snapshot.capturedAt) + } + } + metrics.rejectedAnchors++ + return { + status: 'unverifiable' as const, + reason: resolution.reason, + capturedAgeMs: Math.max(0, Date.now() - snapshot.capturedAt) + } + }) + return { capability: WSL_RELAY_CAPABILITIES.processIdentity, results } + } + ) +} diff --git a/src/main/providers/wsl-guest-foreground-process-resolution.ts b/src/shared/wsl-guest-foreground-process-resolution.ts similarity index 96% rename from src/main/providers/wsl-guest-foreground-process-resolution.ts rename to src/shared/wsl-guest-foreground-process-resolution.ts index f76544aa1eb..6efe5e53817 100644 --- a/src/main/providers/wsl-guest-foreground-process-resolution.ts +++ b/src/shared/wsl-guest-foreground-process-resolution.ts @@ -1,5 +1,5 @@ -import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import type { WslShellProcessAnchor } from '../../shared/wsl-shell-process-anchor' +import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' +import type { WslShellProcessAnchor } from './wsl-shell-process-anchor' import type { WslGuestProcessInventory, WslGuestProcessRow diff --git a/src/main/providers/wsl-guest-process-inventory-parser.ts b/src/shared/wsl-guest-process-inventory-parser.ts similarity index 100% rename from src/main/providers/wsl-guest-process-inventory-parser.ts rename to src/shared/wsl-guest-process-inventory-parser.ts diff --git a/src/shared/wsl-hook-relay-contract.ts b/src/shared/wsl-hook-relay-contract.ts index 87f099e6e0d..04e23f1514f 100644 --- a/src/shared/wsl-hook-relay-contract.ts +++ b/src/shared/wsl-hook-relay-contract.ts @@ -10,6 +10,39 @@ 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' +import type { WslShellProcessAnchor } from './wsl-shell-process-anchor' + +/** Capabilities advertised by the resident WSL relay. Keep process identity + * separate from hooks so disabling hooks never disables foreground evidence. */ +export const WSL_RELAY_CAPABILITIES = { + hooks: 'hooks', + fs: 'fs.home', + processIdentity: 'process.identity' +} as const + +export const WSL_RELAY_PROCESS_METHODS = { + identityRead: 'process.identity.read' +} as const +export const WSL_RELAY_HOOKS_SET_ENABLED_METHOD = 'hooks.setEnabled' + +export type WslRelayIdentityRequest = { + distro: string + anchors: readonly WslShellProcessAnchor[] +} + +export type WslRelayIdentityResult = + | { + status: 'live' + processName: string | null + anchor: WslShellProcessAnchor + capturedAgeMs: number + } + | { + status: 'unverifiable' + reason: string + capturedAgeMs: number + } + /** 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