feat(wsl): route foreground identity through resident relay

This commit is contained in:
Merge Sim
2026-09-03 18:36:45 -07:00
parent 57e1354191
commit 5d2c298937
24 changed files with 1389 additions and 376 deletions
+452
View File
@@ -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/<bundle-version>/`.
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 <distro> --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/<pid>/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`.
@@ -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<WslHookRelayManagerDeps, 'platform'>,
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(),
@@ -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<void> {
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)
@@ -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<void>
getState: (distro: string) => { phase: string; mux?: SshChannelMultiplexer } | undefined
disposed: boolean
requestOptions?: { signal?: AbortSignal; timeoutMs?: number }
}): Promise<WslRelayIdentityResult[]> {
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')
}
}
+13 -5
View File
@@ -277,15 +277,23 @@ export function formatWslRelayFailure(failure: WslRelayStartupFailure): string {
export function buildWslRelaySpawnEnv(
coords: Record<string, string>,
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
}
@@ -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<typeof setTimeout>
reinstallTimer?: ReturnType<typeof setTimeout>
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<void> {
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<string, string | undefined>,
isRunning: (distro: string) => Promise<boolean>,
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<string, WslRelayState>,
recovery: WslRelayRecovery,
stopped: Map<string, string | undefined>,
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<string[]>
): Promise<string | null> {
if (current) {
return current
}
try {
return (await list())[0] ?? null
} catch {
return null
}
}
@@ -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()
})
+144 -190
View File
@@ -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<typeof setTimeout>
reinstallTimer?: ReturnType<typeof setTimeout>
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<string, DistroState>()
/** Distros a hooks-off teardown stopped, so re-enabling can put them back. */
private stoppedByHooksOff = new Map<string, string | undefined>()
private defaultDistro: string | null = null
private disposed = false
private warnedBundleMissing = false
private ensurePromises = new Map<string, Promise<void>>()
constructor(deps: Partial<WslHookRelayManagerDeps> = {}) {
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<void> {
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<typeof readWslRelayProcessIdentity>[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<void> {
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<void> {
const mux = new SshChannelMultiplexer(transport)
state.mux = mux
wireWslRelayLink({
mux,
child,
distro: state.distro,
ingest: this.deps.ingest,
warn: this.deps.warn,
onDead: (reason) => {
if (this.disposed || state.mux !== mux) {
return
}
state.mux = undefined
const wasRunning = state.phase === 'running'
// Why: only a stable run forgives past failures — a connect-then-die
// loop must escalate, not retry every 10s.
if (
wasRunning &&
state.connectedAt !== undefined &&
Date.now() - state.connectedAt >= STABLE_UPTIME_MS
) {
state.failures = 0
}
this.markFailed(state, `relay link for '${state.distro}' ${reason}; scheduling restart`, {
cooldownBaseMs: wasRunning ? RUNNING_TEARDOWN_COOLDOWN_MS : FAILURE_COOLDOWN_BASE_MS
})
}
})
const homeResult = (await mux.request(WSL_HOOK_FS_METHODS.home)) as {
ok?: boolean
home?: string
portFallback?: boolean
boundPort?: number
}
if (homeResult?.ok !== true || typeof homeResult.home !== 'string') {
throw new Error(`relay for '${state.distro}' returned no home dir`)
}
if (homeResult.portFallback === true) {
this.deps.warn(
`[agent-hooks] WSL hook relay (${state.distro}): preferred port occupied in guest; bound ${homeResult.boundPort ?? 'unknown'} (endpoint-file re-coordination)`
)
}
state.guestHome = homeResult.home
state.guestEndpointFilePath = wslHookRelayEndpointFilePath(homeResult.home, instanceKey)
await 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<string | null> {
if (this.defaultDistro) {
return this.defaultDistro
}
try {
const distros = await this.deps.listDistros()
this.defaultDistro = distros[0] ?? null
} catch {
this.defaultDistro = null
}
return this.defaultDistro
}
}
export const wslHookRelayManager = new WslHookRelayManager()
+31 -53
View File
@@ -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<string>()
const evidenceEpoch = Date.now()
const wslByDistro = new Map<string, WslGuestProcessInventoryRead>()
const wslBySession = new Map<
string,
Awaited<ReturnType<typeof wslRelayIdentityReader.readBatch>>[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<void> => {
while (nextDistroIndex < distroList.length) {
const distro = distroList[nextDistroIndex++]!
wslByDistro.set(
distro,
await readWslGuestProcessInventory(distro, {
deadlineMs: opts?.deadlineMs,
signal: opts?.signal
})
)
const byDistro = new Map<string, typeof result.sessions>()
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<string, ReturnType<typeof createWslGuestProcessIndexes>>()
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(
+9 -1
View File
@@ -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)
@@ -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<string |
if (!anchor) {
return null
}
const read = await readWslGuestProcessInventory(wslDistro)
if (ptyProcesses.get(id) !== proc || read.status !== 'ok') {
return null
}
const resolution = resolveWslGuestForegroundProcess(read.inventory, anchor)
const peers = [...ptyWslDistroById.entries()]
.filter(([, distro]) => distro?.toLowerCase() === wslDistro.toLowerCase())
.map(([peerId]) => ptyWslShellAnchors.get(peerId))
.filter((candidate): candidate is NonNullable<typeof candidate> => 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<stri
if (!anchor) {
return null
}
const read = await readWslGuestProcessInventory(wslDistro)
if (ptyProcesses.get(id) !== proc || read.status !== 'ok') {
const read = await wslRelayIdentityReader.read(wslDistro, anchor)
if (ptyProcesses.get(id) !== proc || read.status !== 'live') {
return null
}
const resolution = resolveWslGuestForegroundProcess(read.inventory, anchor)
if (ptyProcesses.get(id) !== proc || resolution.status !== 'live') {
return null
}
ptyWslShellAnchors.set(id, resolution.anchor)
return resolution.processName
ptyWslShellAnchors.set(id, read.anchor)
return read.processName
}
try {
const resolution = await resolveAgentForegroundProcessWithAvailability(
@@ -4,7 +4,7 @@ import type { PtyStartupIngress } from '../../shared/pty-startup-ingress'
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
import { normalizeLocalCallerSessionId } from './local-pty-launch-helpers'
import type { WslShellProcessAnchor } from '../../shared/wsl-shell-process-anchor'
import { resetWslGuestProcessInventory } from './wsl-guest-process-inventory'
import { wslRelayIdentityReader } from './wsl-relay-identity-reader'
export type PtyShutdownOperation = {
promise: Promise<void>
@@ -133,7 +133,7 @@ export function clearPtyState(id: string): void {
ptyTerminationMode.delete(id)
ptyReportsChildExitStatus.delete(id)
ptyPhysicalExits.delete(id)
resetWslGuestProcessInventory()
wslRelayIdentityReader.reset()
}
/**
@@ -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<string, WslGuestProcessInventoryRead>()
const identityByPty = new Map<
string,
Awaited<ReturnType<typeof wslRelayIdentityReader.readBatch>>[number]
>()
const distros = [...wslByDistro.keys()]
let nextDistroIndex = 0
const readNextDistro = async (): Promise<void> => {
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<typeof anchor> => 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<string, ReturnType<typeof createWslGuestProcessIndexes>>()
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)
@@ -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
@@ -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()
})
})
@@ -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<WslRelayIdentityResult>
readBatch: (
distro: string,
anchors: readonly WslShellProcessAnchor[],
options?: { signal?: AbortSignal; timeoutMs?: number }
) => Promise<WslRelayIdentityResult[]>
reset: () => void
}
export function createWslRelayIdentityReader(
manager: Pick<WslHookRelayManager, 'readProcessIdentity'> = 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<WslRelayIdentityResult[]> => {
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()
+2
View File
@@ -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)
@@ -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
@@ -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<void> {
logStartupMilestone('app-ready')
@@ -205,15 +204,10 @@ export async function initializeReadyFoundation(): Promise<void> {
// 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.
+69 -26
View File
@@ -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<void> {
const windowsPort = Number(process.env.ORCA_AGENT_HOOK_PORT ?? '')
const token = process.env.ORCA_AGENT_HOOK_TOKEN ?? ''
if (!Number.isInteger(windowsPort) || windowsPort <= 0 || token.length === 0) {
process.stderr.write('[wsl-hook-relay] missing ORCA_AGENT_HOOK_PORT/TOKEN in env\n')
process.exit(1)
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<void> {
// 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<void> {
// 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
+208
View File
@@ -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<CachedCapture> | 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<WslGuestProcessRow, 'command' | 'tty'>; 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<CachedCapture> {
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 }
}
)
}
@@ -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
+33
View File
@@ -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