mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
feat(wsl): route foreground identity through resident relay
This commit is contained in:
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user