mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Merge remote-tracking branch 'origin/main' into Jinwoo-H/manual-worktree-delete
This commit is contained in:
@@ -60,6 +60,9 @@ pnpm-debug.log*
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Prod release scan output (accidental adds)
|
||||
prod-release-scan-*.md
|
||||
|
||||
# Benchmark run output
|
||||
/tools/benchmarks/results/*.json
|
||||
/.bench-fixtures/
|
||||
|
||||
@@ -59,4 +59,31 @@ for (const platform of PLATFORMS) {
|
||||
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
|
||||
}
|
||||
|
||||
// WSL agent-hook relay: a hooks-only guest receiver launched inside WSL
|
||||
// distros via wsl.exe. Pure Node built-ins (no node-pty/@parcel/watcher),
|
||||
// so a single platform-independent bundle suffices; it ships inside the
|
||||
// Windows app via the same out/relay extraResources mapping.
|
||||
{
|
||||
const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
|
||||
const outDir = join(ROOT, 'out', 'relay', 'wsl')
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
await build({
|
||||
entryPoints: [wslEntry],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'wsl-agent-hook-relay.js'),
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
})
|
||||
const content = readFileSync(join(outDir, 'wsl-agent-hook-relay.js'))
|
||||
const hash = createHash('sha256').update(content).digest('hex').slice(0, 12)
|
||||
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
|
||||
console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`)
|
||||
}
|
||||
|
||||
console.log('Relay build complete.')
|
||||
|
||||
@@ -279,10 +279,12 @@ function getPatchedNodePtyRebuildReason() {
|
||||
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
|
||||
// patch only lands in the source-built build/Release artifacts.
|
||||
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
|
||||
const missingArtifact = [
|
||||
resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
|
||||
resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')
|
||||
].find((artifactPath) => !existsSync(artifactPath))
|
||||
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
|
||||
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
|
||||
if (process.platform === 'darwin') {
|
||||
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
|
||||
}
|
||||
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
|
||||
|
||||
if (!missingArtifact) {
|
||||
return null
|
||||
|
||||
@@ -117,6 +117,40 @@ describe('ensure-native-runtime', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'keeps the fast path when the platform-specific patched artifacts exist',
|
||||
() => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' })
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writePatchedNodePtyBuildArtifacts(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
|
||||
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
function mkTempProject() {
|
||||
@@ -161,7 +195,7 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
)
|
||||
}
|
||||
|
||||
function writeLoadableNativeModules(projectDir) {
|
||||
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
|
||||
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
|
||||
|
||||
@@ -173,7 +207,8 @@ const { appendFileSync, existsSync } = require('node:fs')
|
||||
|
||||
exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
const rebuilt = existsSync(process.env.ORCA_NATIVE_TEST_MARKER)
|
||||
const dir = rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/'
|
||||
const dir = ${JSON.stringify(nativeDir)} ??
|
||||
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
|
||||
return { dir, module: {} }
|
||||
}
|
||||
@@ -190,7 +225,9 @@ function writePatchedNodePtyBuildArtifacts(projectDir) {
|
||||
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
|
||||
mkdirSync(buildDir, { recursive: true })
|
||||
writeFileSync(join(buildDir, 'pty.node'), '')
|
||||
writeFileSync(join(buildDir, 'spawn-helper'), '')
|
||||
if (process.platform === 'darwin') {
|
||||
writeFileSync(join(buildDir, 'spawn-helper'), '')
|
||||
}
|
||||
}
|
||||
|
||||
function writeFakePnpm(binDir) {
|
||||
|
||||
@@ -349,10 +349,12 @@ function getPatchedNodePtyRebuildReason() {
|
||||
// Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
|
||||
// load successfully in Electron while missing the patched fd/error handling.
|
||||
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
|
||||
const missingArtifact = [
|
||||
resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
|
||||
resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')
|
||||
].find((artifactPath) => !existsSync(artifactPath))
|
||||
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
|
||||
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
|
||||
if (process.platform === 'darwin') {
|
||||
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
|
||||
}
|
||||
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
|
||||
|
||||
if (!missingArtifact) {
|
||||
return null
|
||||
|
||||
@@ -412,5 +412,7 @@ function writePatchedNodePtyBuildArtifacts(projectDir) {
|
||||
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
|
||||
mkdirSync(buildDir, { recursive: true })
|
||||
writeFileSync(join(buildDir, 'pty.node'), '')
|
||||
writeFileSync(join(buildDir, 'spawn-helper'), '')
|
||||
if (process.platform === 'darwin') {
|
||||
writeFileSync(join(buildDir, 'spawn-helper'), '')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
# Agent Status over WSL (STA-1515)
|
||||
|
||||
Status: implemented and rig-validated (2026-07-09 round 3 + 2026-07-10 round-4 re-run
|
||||
pinned to the hardened build, Windows 11 + WSL2 NAT): Claude end-to-end live —
|
||||
provisioning, working→done in the store, completion toast, loopback-only posture, and
|
||||
restart resume over a daemon-surviving PTY with the instance-keyed endpoint dir reused
|
||||
across restarts. The round-4 re-run also proved the `--exec` spawn form live (host
|
||||
process table) and the stale-exit reinstall upgrading the guest to the new bundle
|
||||
version in place. Residual: Codex's done/Stop leg unproven live — env-blocked on the
|
||||
rig (no dev-profile Codex credentials AND the model backend is unreachable from the
|
||||
guest under NAT); everything that fired behaved correctly. Confirm on a credentialed
|
||||
rig with a guest-reachable backend, ideally on a fresh distro to also observe the
|
||||
deferred Codex trust entries landing after config.toml is seeded.
|
||||
Owner: brennanb2025. Linear: STA-1515.
|
||||
Precedent this mirrors: the SSH agent-hook relay (`src/relay/agent-hook-server.ts`,
|
||||
`src/shared/agent-hook-relay.ts`, ingest at `agentHookServer.ingestRemote` in
|
||||
`src/main/agent-hooks/server.ts`).
|
||||
|
||||
## Background — how we got here
|
||||
|
||||
GitHub issue `7565` reported OMP agents in WSL worktrees disappearing from the worktree
|
||||
sidebar after v1.4.124. Diagnosis split it into a regression and a pre-existing class gap:
|
||||
|
||||
- The **regression** was a title-normalization change (PR `7447`) that stopped idle OMP
|
||||
titles from producing the sidebar's title-derived fallback row. Decision: the sidebar is
|
||||
moving to **hook-driven rows only** (fallback removal in flight, separate PR), so the
|
||||
fallback was not restored.
|
||||
- The **class gap** is that agent hooks have never worked from inside WSL for any agent.
|
||||
Two scoped PRs fixed it for OMP alone (merged, live-validated on a Windows+WSL2-NAT rig):
|
||||
- PR `7642` — Orca-managed WSL shells wrap interactive `omp` invocations with
|
||||
`--extension "$ORCA_OMP_STATUS_EXTENSION"` (the env var is WSLENV `/p`-translated so
|
||||
the WSL process reads the extension out of the Windows filesystem via `/mnt/c`).
|
||||
- PR `7641` — when the extension's loopback POST cannot connect, it delivers via
|
||||
Windows-side `/mnt/c/Windows/System32/curl.exe` (a Windows process, so *its*
|
||||
`127.0.0.1` is the loopback Orca actually binds). Fire-and-forget spawn,
|
||||
`--noproxy 127.0.0.1`, memoized WSL/curl probes, load-tolerant timeouts
|
||||
(`--connect-timeout 3 --max-time 10`; 0.5s dropped events under load).
|
||||
|
||||
This document is the full context for the general fix: every other hook client is still
|
||||
dead from WSL, and the hooks-only sidebar change makes this work the gate for the
|
||||
Windows+WSL story.
|
||||
|
||||
## Why hooks don't work on Windows+WSL — two independent gaps
|
||||
|
||||
### Gap A — transport
|
||||
|
||||
The hook listener binds `127.0.0.1` only, deliberately (`src/main/agent-hooks/server.ts`,
|
||||
`listen(0, '127.0.0.1')`; auth via `X-Orca-Agent-Hook-Token`, 403 otherwise). Every hook
|
||||
client POSTs to a hardcoded `http://127.0.0.1:$ORCA_AGENT_HOOK_PORT/hook/<source>`.
|
||||
|
||||
WSL2 under default **NAT** networking is a VM with its own network namespace. Microsoft's
|
||||
localhost forwarding is **one-way (Windows→WSL only)**: `127.0.0.1` inside WSL is WSL's
|
||||
own loopback, so every POST dies `ECONNREFUSED` — silently, because hook clients are
|
||||
deliberately fail-open. Reaching Windows from WSL would require the host vNIC IP (changes
|
||||
per boot) + a non-loopback listener bind + a firewall rule — all three conflict with the
|
||||
loopback-only security posture.
|
||||
|
||||
The env coordinates DO cross correctly (`src/main/pty/wsl-orca-env.ts`
|
||||
`addOrcaWslInteropEnv`: WSLENV `PORT/u TOKEN/u ENV/u VERSION/u` plus
|
||||
`ORCA_AGENT_HOOK_ENDPOINT/p` path-translated; called from `src/main/ipc/pty.ts` and
|
||||
`src/main/daemon/pty-subprocess.ts`). The address is simply unreachable.
|
||||
|
||||
Opt-in **mirrored** networking (Win11, `.wslconfig`) shares loopback and makes plain fetch
|
||||
work — the fix must not fight it. No `wslinfo` probing is needed: under mirrored mode the
|
||||
relay's preferred-port bind collides with the Windows listener and the `EADDRINUSE`
|
||||
fallback (below) handles it, while clients that go straight to the shared loopback reach
|
||||
the Windows listener directly. Both delivery paths stay valid.
|
||||
|
||||
### Gap B — installation
|
||||
|
||||
Hook configs and scripts are written to the **Windows** home by every hook service:
|
||||
Claude `settings.json` + managed scripts, Codex config, Gemini/Cursor/Droid/Devin/Grok/
|
||||
Copilot scripts, Amp/OpenCode plugin files, the Pi/OMP extension file. An agent inside WSL
|
||||
reads the **WSL-side** `$HOME` and sees none of it. There is zero WSL-targeted install
|
||||
code in `src/main/agent-hooks/` or any hook service. SSH remotes have the exact precedent
|
||||
needed: dedicated remote installers (`src/main/ssh/ssh-relay-session.ts` remote
|
||||
settings.json handling; PR `7744` installed Droid/Copilot hooks over SSH).
|
||||
|
||||
Consequence: even mirrored-networking users get no hooks — transport fine, configs absent.
|
||||
OMP escapes Gap B by *pointing across* the boundary (`/mnt/c` path via `/p` translation)
|
||||
rather than installing WSL-side; that trick can carry file *content* for some clients, but
|
||||
shell hooks still execute inside WSL and then hit Gap A regardless.
|
||||
|
||||
## Transport map — every client, how it posts
|
||||
|
||||
Endpoint file contract: `writeEndpointFile` (`src/shared/agent-hook-listener.ts`) emits
|
||||
exactly four keys (`ORCA_AGENT_HOOK_PORT/TOKEN/ENV/VERSION`) to `endpoint.env` (POSIX) /
|
||||
`endpoint.cmd` (Windows) — **no host field**. Shell clients source it to refresh stale
|
||||
coords after an Orca restart; node clients parse it. It is never executed as a delivery
|
||||
script. Clients prefer endpoint-FILE coords over env (restart re-coordination) — any
|
||||
transport change must preserve that property.
|
||||
|
||||
| Client | Mechanism | Runtime that POSTs |
|
||||
| --- | --- | --- |
|
||||
| Claude, Codex, Gemini, Cursor, Droid, Devin, Grok | managed shell script | `curl` (POSIX) / `curl.exe` (Windows), built in `src/main/agent-hooks/installer-utils.ts` |
|
||||
| Copilot | managed script | `curl` (POSIX) / PowerShell `Invoke-WebRequest` (Windows) |
|
||||
| command-code | managed script (parse-not-source hardened) | `curl` |
|
||||
| Amp, OpenCode | in-process node plugin | `fetch` |
|
||||
| Pi / OMP | bundled in-process extension (`src/main/pi/agent-status-extension-source.ts`) | `fetch`, now with the WSL curl.exe fallback |
|
||||
|
||||
All of them target `127.0.0.1`.
|
||||
|
||||
## Status quo + why this gates the hooks-only sidebar
|
||||
|
||||
Today the worktree-card rows still have a title-derived fallback producer
|
||||
(`src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts`), so WSL users
|
||||
DO currently see rows for title-rich agents (Claude `✳`/spinner titles, Gemini glyphs) —
|
||||
degraded (generic text, no prompt/last-message preview, no notifications) but present.
|
||||
Title-poor agents are dark (Codex — hence GH `6907`). When the hooks-only change removes
|
||||
that producer, **every non-OMP agent in a WSL worktree loses its card row entirely until
|
||||
this work ships**. Hook fidelity adds: prompt + last-assistant-message previews,
|
||||
waiting/blocked precision, completion notifications, AI Vault / native chat session
|
||||
integration.
|
||||
|
||||
## Solution design — WSL relay + WSL-side installation
|
||||
|
||||
### Transport (Gap A): guest-resident relay, host-owned stdio
|
||||
|
||||
Run a small receiver **inside WSL on WSL's own loopback, listening on the very port the
|
||||
clients were already given** (`$ORCA_AGENT_HOOK_PORT` — free inside WSL, since that port
|
||||
only exists on the Windows side). Unmodified clients then deliver successfully with
|
||||
**zero client changes**; the reporter's diagnostic relay in GH `7565` proved this shape
|
||||
live. Forward each parsed envelope to the Windows host over the relay's **own stdio**
|
||||
(Orca spawns it via `wsl.exe`, so it owns that pipe). Ingest through the existing trust
|
||||
boundary: `agentHookServer.ingestRemote` (`src/main/agent-hooks/server.ts`), envelope
|
||||
shape `src/shared/agent-hook-relay.ts` — identical to the SSH relay, which runs a
|
||||
loopback-only receiver on the remote box and forwards over the SSH control channel.
|
||||
|
||||
**Port binding.** Bind the inherited `$ORCA_AGENT_HOOK_PORT` first — it keeps every
|
||||
already-crossed coordinate (env and the `/p`-translated endpoint file) truthful with zero
|
||||
divergence. On `EADDRINUSE` inside the guest, fall back to the SSH relay's own pattern:
|
||||
bind `127.0.0.1:0`, write a **WSL-side endpoint file**, and point WSL PTYs'
|
||||
`ORCA_AGENT_HOOK_ENDPOINT` at it (clients already prefer endpoint-file coords over env).
|
||||
The relay writes that WSL-side endpoint file in **both** modes so restart re-coordination
|
||||
never depends on `/mnt/c` translation being readable.
|
||||
|
||||
Lifecycle: one relay per distro **per Orca instance** (concurrent instances have distinct
|
||||
ports, so guest listeners never collide); **ensure** — not just start — whenever a WSL PTY
|
||||
exists: first spawn *and* daemon-PTY reattach after an Orca restart (WSL PTYs survive in
|
||||
the daemon; the new instance has a new port + token and must respawn the relay before
|
||||
surviving agents re-coordinate). The relay **exits when its stdin closes**: a lingering
|
||||
guest listener would let WSL's own Windows→WSL forwarder grab the freed Windows-side port
|
||||
and blackhole stale Windows-side hook posts. Restart if WSL restarts; token still
|
||||
validated at the relay's HTTP receiver; harmless under mirrored networking (bind
|
||||
fallback) and inert on non-WSL platforms (ensure only fires from WSL PTY spawns).
|
||||
|
||||
Reliability contract (invariant class `agent-session.hook-transport`): hook clients are
|
||||
fail-open silent, so the relay must not be — spawn failures, `EADDRINUSE` fallback, and
|
||||
forward errors each leave a diagnosable breadcrumb; the `wsl.exe` "Catastrophic failure
|
||||
(E_UNEXPECTED)" retry is **bounded** with backoff, never a spawn loop. Oracle: provider-
|
||||
contract tests with fault injection (stdin close → exit, occupied port → fallback + file
|
||||
rewrite, envelope round-trip to `ingestRemote`) rather than end-to-end flows only.
|
||||
|
||||
Design notes from a survey of comparable WSL-capable tools (kept nameless per policy):
|
||||
- Guest-resident component + host-owned channel + guest-side installation, explicitly
|
||||
reusing the tool's SSH-remote machinery, **is the established pattern**. No surveyed
|
||||
tool makes guest processes dial back to a Windows-localhost listener — the merged OMP
|
||||
curl.exe stopgap is the outlier as a primary path (but see the round-4 revised
|
||||
stance below: it survives as the no-node fallback).
|
||||
- Prefer host-owned **stdio** over Windows→WSL localhost port forwarding (wslhost
|
||||
forwarding is known-flaky under load; one surveyed tool dials the distro vNIC IP just to
|
||||
avoid it — stdio sidesteps the question entirely).
|
||||
- WSL offers no persistent control channel between separate `wsl.exe` invocations —
|
||||
collapse the relay's ensure-installed + launch into **one idempotent script per spawn**.
|
||||
- Install into the guest from inside the guest (download/extract in WSL) or stream the
|
||||
binary through `wsl.exe` stdin — not by copying through `/mnt/c`.
|
||||
|
||||
### Installation (Gap B): WSL-side hook installers
|
||||
|
||||
Write agent hook configs/scripts into the WSL-side home, per agent, analogous to the SSH
|
||||
remote installers — via `wsl.exe`-executed scripts (preferred, mirrors SSH most closely)
|
||||
or `\\wsl.localhost\<distro>\...` writes. Without this half, the relay receives nothing:
|
||||
the hook clients themselves are absent from the WSL filesystem.
|
||||
|
||||
## Alternatives considered (and why not)
|
||||
|
||||
1. **Endpoint-file host/URL field** — the file already crosses into WSL (`/p`-translated),
|
||||
but clients read only PORT/TOKEN and hardcode the host, so all ~13 still need edits;
|
||||
and a WSL-reachable listener bind breaks the loopback-only posture (LAN exposure,
|
||||
firewall prompts).
|
||||
2. **Replicate the curl.exe bridge per client** — the shell clients share ~2 generated
|
||||
builders so it is cheaper than it sounds, but it is N point fixes, requires WSL interop
|
||||
enabled (`/etc/wsl.conf` can disable it), pays a per-event process spawn (load-sensitive,
|
||||
see validation facts), and keeps the ecosystem-outlier direction.
|
||||
3. **Listener-side bind changes / rely on mirrored networking** — posture conflict /
|
||||
opt-in only.
|
||||
4. **OSC 9999 in-band status** (`src/shared/agent-status-osc.ts`, parsed per-pane in
|
||||
`pty-transport.ts` and `orca-runtime.ts`) — zero-network and pane-attributed, but only
|
||||
viable for in-process clients and carries status payloads, not the full hook event
|
||||
vocabulary (prompts, tools, completion) — cannot replace the pipeline.
|
||||
|
||||
## Facts + gotchas from the 2026-07-08 Windows-rig validation
|
||||
|
||||
- curl.exe interop delivery works under NAT (shipped for OMP), but per-event process spawn
|
||||
is load-sensitive: `--connect-timeout 0.5` dropped 3/3 events to a *healthy* listener
|
||||
under load; fine at 3s. A resident relay avoids per-event spawns entirely.
|
||||
- `wslinfo --networking-mode` distinguishes NAT vs mirrored.
|
||||
- Clients prefer endpoint-FILE coords over env. Testing gotcha: unset
|
||||
`ORCA_AGENT_HOOK_ENDPOINT` in synthetic tests or events go to the real running app.
|
||||
- Server ingest silently drops paneKeys that are not `uuid:uuid`-shaped — use real-shaped
|
||||
keys in synthetic validation.
|
||||
- OMP is a Bun single-file binary; Bun's `node:child_process`/`fetch` compat held. Other
|
||||
in-process clients run inside their agents' runtimes — verify per runtime.
|
||||
- Environmental: fresh WSL 2.7.10 intermittently threw "Catastrophic failure
|
||||
(E_UNEXPECTED)" from `wsl.exe -d <distro> -- bash -lc` under concurrent spawn load
|
||||
(cleared by `wsl --terminate`). The relay spawn path should tolerate/retry this.
|
||||
- Fork-PR CI runs sit in `action_required` until approved:
|
||||
`gh api repos/stablyai/orca/actions/runs/<id>/approve -X POST`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
On a default-config Windows 11 + WSL2 **NAT** machine: launch **Codex or Claude**
|
||||
(explicitly not OMP) in a WSL worktree → live hook-driven worktree-card row with status
|
||||
transitions and a completion notification; hook listener still bound to Windows loopback
|
||||
only; zero per-client transport changes; hooks installed WSL-side automatically (no manual
|
||||
config); harmless under mirrored networking and inert on non-WSL platforms. After an Orca
|
||||
restart with the WSL agent still running (daemon-surviving PTY), status events resume
|
||||
without relaunching the agent.
|
||||
|
||||
## 2026-07-09 Windows-rig validation follow-ups
|
||||
|
||||
The first live GUI run proved every mechanism in isolation but failed end-to-end, yielding
|
||||
two fixes:
|
||||
|
||||
1. **Link death must be handled, not just child death.** A mux protocol error or timeout
|
||||
can kill the host↔guest link while the guest process (and its 204-returning receiver)
|
||||
stays alive — the exact observed signature: hooks POST 204, store never populates.
|
||||
`wsl-hook-relay-link.ts` now guarantees exactly-once death handling from either signal;
|
||||
the manager breadcrumbs it, kills the child, and self-restarts after a short cooldown
|
||||
(a live agent session produces no new PTY spawns to re-trigger ensure).
|
||||
`ORCA_WSL_HOOK_RELAY_DEBUG=1` traces every received envelope pre-ingest so a live rig
|
||||
can pinpoint any residual drop. The full host chain is pinned by a live integration
|
||||
test (real bundle over real child stdio through the real manager into a real
|
||||
`ingestRemote`).
|
||||
2. **(Round 2) The renderer's SSH-era ownership gate dropped `wsl:*` events.** With the
|
||||
link fixed, envelopes reached `ingestRemote` and the durable cache, but
|
||||
`useIpcEvents.applyAgentStatus` compares the stamped connectionId against the owning
|
||||
repo's — `"wsl:<distro>" !== null` for a local repo, so every WSL-relayed status died
|
||||
before `setAgentStatus`/notifications. Fix: `wsl:*` ids are transport provenance, not
|
||||
ownership — the gate normalizes them to local (null) via
|
||||
`isWslHookRelayConnectionId`, while still rejecting WSL-stamped events against
|
||||
SSH-owned repos. Provenance stays stamped (it made the drop diagnosable in the first
|
||||
place).
|
||||
3. **Codex reads a redirected home.** Orca launches WSL Codex with `CODEX_HOME` pointed at
|
||||
the managed runtime home (`~/.local/share/orca/codex-runtime-home/home`), so installing
|
||||
hooks to `~/.codex` left Codex dark. The installers now accept an explicit codex home;
|
||||
the trust write into `config.toml` is deferred while that file doesn't exist (the
|
||||
launch path seeds it only-if-absent — creating it first would cancel the seed), and the
|
||||
manager re-runs the idempotent installers on later ensures (throttled) to upsert trust
|
||||
once the seed lands. Consequence: the very first WSL Codex session after a cold relay
|
||||
may miss hooks; the next one has them.
|
||||
|
||||
## 2026-07-09 adversarial-review hardening (pre-rig round 3)
|
||||
|
||||
Four independent review lenses over the full diff; confirmed findings fixed:
|
||||
|
||||
- **Endpoint identity (all 4 reviewers)**: the guest endpoint dir was keyed by the
|
||||
ephemeral Windows hook port, so a daemon-surviving agent kept sourcing the DEAD
|
||||
`port-P1` file after an Orca restart — breaking the restart-resume acceptance criterion
|
||||
and regressing shipped OMP recovery. Now keyed by a restart-stable instance key
|
||||
(hash of the Windows endpoint file path = userData + namespace, crossed via
|
||||
`ORCA_WSL_HOOK_INSTANCE`): the restarted instance's relay REWRITES the same file, which
|
||||
is exactly what re-coordinates survivors.
|
||||
- **Restart policy**: every failure now arms the restart timer (one failed relaunch no
|
||||
longer ends self-recovery), and the timer probes `wsl --list --running` first — `wsl -d`
|
||||
BOOTS a stopped distro, so recovery must never resurrect a VM the user shut down; a
|
||||
stopped distro's state is dropped instead (next WSL terminal re-ensures). Failure
|
||||
counters only reset after 2 min of stable uptime, so connect-then-die loops escalate to
|
||||
the 10-min cap instead of cycling every 10s.
|
||||
- **Install-dir versioning**: the guest install dir is namespaced by bundle version, so
|
||||
concurrent Orca instances with different bundles (dev + prod) never reinstall over each
|
||||
other; tmp files carry the guest PID. The install spawn also gained the 30s timeout it
|
||||
was missing (a wedged wsl.exe could previously pin the state machine at 'starting'
|
||||
forever).
|
||||
- **Guest node resolution**: candidates (PATH, nvm glob, fixed paths) are each
|
||||
version-probed, first pass wins — an apt node 12 on PATH no longer masks an nvm node 20
|
||||
into a false "no node >= 18" 10-minute cooldown.
|
||||
- **wsl.exe text handling**: `WSL_UTF8=1` on all spawns + NUL-stripping on stderr, so the
|
||||
"Catastrophic failure" transient-retry matcher and breadcrumbs survive UTF-16LE output.
|
||||
- Smaller: ordered post-sentinel chunk handoff (frame-decoder desync race), port-fallback
|
||||
breadcrumb now reaches host logs via the home handshake, bad home reply fails the
|
||||
connect (was: silently 'running' without installs), missing-bundle warn-once, distro
|
||||
map keys case-normalized, `disposeAll` wired to app `will-quit`, single-spawn Codex
|
||||
trust catch-up via a one-shot 60s reinstall timer.
|
||||
|
||||
Accepted gaps (reviewed, deliberately not addressed here): old version-namespaced
|
||||
install dirs accrete across upgrades (~200KB each); an outdated running daemon
|
||||
/p-translates the guest endpoint path until it restarts (hook scripts fall back to env
|
||||
coords, which same-port binding keeps correct); `wslDistroCache` caches a transient
|
||||
empty list for the app run (pre-existing semantics, now load-bearing for default-distro
|
||||
resolution); default-distro resolution caches the first answer for the app run.
|
||||
|
||||
## 2026-07-09 round-4 external adversarial review
|
||||
|
||||
A second adversarial sweep (five independent lenses: guest relay + fs bridge, host
|
||||
lifecycle state machine, app integration + renderer gate, design-vs-alternatives, and a
|
||||
platform fact-check of every WSL claim). Design verdict: the guest-resident relay over
|
||||
host-owned stdio is the right architecture — the zero-per-client-change chokepoint is
|
||||
what the curl.exe alternative cannot match, and the lifecycle weight is inherent to any
|
||||
guest-resident helper. Confirmed findings, all fixed on this branch:
|
||||
|
||||
- **`dropState` identity race (major)**: the recovery timer re-checked state identity
|
||||
only BEFORE the async `wsl --list --running` probe; an ensure() landing during the
|
||||
probe could get its fresh state deleted by key — orphaning a live relay child outside
|
||||
the map (unkillable by `disposeAll`, duplicate relay on next ensure). Fixed: identity
|
||||
re-check after the probe await + identity-guarded delete in the manager.
|
||||
- **Distro-running probe failed OPEN**: any probe error (including its 10s timeout)
|
||||
reported "running", so recovery could `wsl -d` — and thereby BOOT — a distro the user
|
||||
shut down, in exactly the wedged-wsl.exe failure mode where the probe errors. Now
|
||||
fails closed: drop the state; the next WSL PTY spawn re-ensures.
|
||||
- **Spawn form hardened to `--exec`**: `wsl.exe -- <cmd>` routes through the distro's
|
||||
default login shell (Microsoft docs: only `--exec` runs "without using the default
|
||||
Linux shell"), so a fish/nushell chsh could mangle the launch; `--exec sh -c`/-`s`
|
||||
bypasses it and passes argv verbatim (no `$`-preprocessing, escaping shim dropped) —
|
||||
same form as the Codex WSL login spawn.
|
||||
- **Post-sentinel handoff microtask**: pending chunks flushed synchronously inside the
|
||||
mux constructor, before the manager could register notification handlers — an
|
||||
envelope arriving in the trailing bytes dispatched to zero handlers (recovered only
|
||||
by the later replay request). Flush now rides a microtask: after the caller's
|
||||
synchronous wiring, still ahead of any subsequent stdout IO event.
|
||||
- **Relay process posture**: the guest relay now mirrors the SSH relay's
|
||||
`uncaughtException` (log + exit → manager respawns) / `unhandledRejection` (log +
|
||||
survive) handlers.
|
||||
- **Replay cache recency cap**: the WSL relay has no per-pane teardown signal, so the
|
||||
per-pane replay cache grew for the relay's lifetime; now capped at 256 panes,
|
||||
evicting longest-idle first (meta map kept in lockstep). Backstop for SSH too.
|
||||
- Smaller: guest launch script derives the stale-exit code from the shared contract
|
||||
constant (was a hardcoded 42 twin); one-shot reinstall timer refuses to arm after
|
||||
dispose; fs-bridge scope comment states the lexical (symlink-following) bound
|
||||
honestly. New oracles: sentinel unit suite (chunk splits, overflow kill, timeout,
|
||||
microtask handoff), fs-bridge scoping suite, 403 + fallback endpoint-file rewrite,
|
||||
cache-cap eviction, and the recovery/manager race regressions.
|
||||
|
||||
**Revised stance on the OMP curl.exe bridge — keep it, do not retire.** The relay
|
||||
requires node ≥ 18 in the distro; a fresh WSL Ubuntu ships none, Codex CLI is a native
|
||||
binary that brings none, and Claude Code's native installer no longer implies a system
|
||||
node. A distro running only Codex would hit the no-node cooldown and stay dark — the
|
||||
exact GH `6907` shape. The interop bridge is the one delivery path with no guest
|
||||
runtime requirement, so it stays as the documented no-node fallback (currently wired
|
||||
for OMP; extending it to the shared shell-script builders is the tracked follow-up if
|
||||
no-node distros show up in telemetry). The relay remains the primary path: resident
|
||||
(no per-event spawn cost) and interop-independent.
|
||||
|
||||
## Implementation map
|
||||
|
||||
- Guest: `src/relay/wsl-agent-hook-relay.ts` (entry; exits on stdin close),
|
||||
`src/relay/wsl-hook-fs-bridge.ts` (home-scoped fs RPCs for installs),
|
||||
`src/relay/agent-hook-server.ts` (`token`/`preferredPort` options + `EADDRINUSE`
|
||||
fallback). Bundled by `config/scripts/build-relay.mjs` → `out/relay/wsl/`.
|
||||
- Host: `src/main/agent-hooks/wsl-hook-relay-manager.ts` (per-distro state machine),
|
||||
`wsl-hook-relay-launch.ts` (bundle resolve, guest launch/install scripts, spawn env,
|
||||
sentinel wait), `wsl-hook-relay-link.ts` (envelope forward + exactly-once link-death
|
||||
handling), `wsl-hook-relay-deps.ts` (DI seam), `wsl-hook-fs-adapter.ts` (SFTP-shaped
|
||||
adapter + `installWslGuestHooks`, which targets Codex's managed runtime home).
|
||||
- Wiring: `buildPtyHostEnv` (`src/main/ipc/pty.ts`) ensures the relay on every WSL spawn
|
||||
and repoints `ORCA_AGENT_HOOK_ENDPOINT` at the guest endpoint file once known;
|
||||
`src/main/pty/wsl-orca-env.ts` picks `/u` vs `/p` by value shape.
|
||||
- Contract shared by both sides: `src/shared/wsl-hook-relay-contract.ts`.
|
||||
- Oracles: `src/relay/wsl-agent-hook-relay.test.ts`,
|
||||
`src/main/agent-hooks/wsl-hook-relay-manager.test.ts` (fault injection: stale-42
|
||||
reinstall, no-node-43 cooldown, bounded E_UNEXPECTED retry, exit re-ensure gating, and a
|
||||
full installer run against an in-memory guest).
|
||||
|
||||
## References
|
||||
|
||||
- GitHub: issues `6907` (Codex/WSL), `7091` + `7565` (OMP, fixed), `7563` (WSL CLI
|
||||
detection, adjacent); PRs `7642` + `7641` (OMP fixes), `7744` (SSH hook installers
|
||||
precedent), `7447` (title-collapse regression).
|
||||
- Linear: STA-1515 (this work; ticket comments carry the same context).
|
||||
- Key files: `src/main/agent-hooks/server.ts`, `src/shared/agent-hook-listener.ts`,
|
||||
`src/shared/agent-hook-relay.ts`, `src/relay/agent-hook-server.ts`, `src/relay/relay.ts`,
|
||||
`src/main/pty/wsl-orca-env.ts`, `src/main/agent-hooks/installer-utils.ts`,
|
||||
`src/main/pi/agent-status-extension-source.ts`, `src/main/ssh/ssh-relay-session.ts`,
|
||||
`src/main/providers/windows-shell-args.ts`, `src/shared/wsl-login-shell-command.ts`.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.132-rc.0",
|
||||
"version": "1.4.133-rc.0",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
# Prod Release Scan — output.md
|
||||
|
||||
**Verdict:** ready with accepted P1 risk
|
||||
**Span:** `v1.4.130` (`31378023`) .. `v1.4.131-rc.2` (`6964bfb23`)
|
||||
**Planned release / RC:** `1.4.131-rc.2` (user asked for 1.4.132-rc2; that tag does not exist — scanned confirmed substitute)
|
||||
**Included PRs:** 8 matched (+ `#7567` referenced only in `#7658` title, not a separate PR); unmatched commits: 5
|
||||
**Batches reviewed:** 6 (agent/mobile, codex/speech/WSL, UI defaults, xplat/remoting, perf, security)
|
||||
**Security model:** `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"` (W6 + all workers)
|
||||
**Orchestration:** 6 same-worktree tasks injected; `task-list`/`dispatch-show` verified
|
||||
|
||||
---
|
||||
|
||||
## Issue table by PR / commit
|
||||
|
||||
### Has issues
|
||||
|
||||
| Ref | Title | Severity | Issue summary |
|
||||
|-----|-------|----------|---------------|
|
||||
| **#7906** | Wake slept agents when opening a worktree on mobile | **P1** (×3) | (1) Non-passive / manual-sleep wake on an **unmounted** worktree creates a tab with `activate:false` but never background-mounts → **no PTY / resume lost**. (2) Mounted manual-sleep can **double-resume** the same provider session (in-place wake + generic resume). (3) Edge-triggered wake can be **lost** if mobile opens during hibernation kill before `hibernatedWakePtyId` is armed. |
|
||||
| **#7906** | (perf composition) | **P1** | Passive completed wake background-mounts the **entire** worktree → O(all saved tabs) PTY/xterm work, defeating unvisited-worktree mass-spawn guard. |
|
||||
| **#7906** | (headless remoting) | **P1** | Wake is a no-op without a live renderer window; headless `orca serve --mobile-pairing` activates without waking slept agents. |
|
||||
| **#7960** | Write in-Codex setting changes back to `~/.codex` | **P1** (×2–3) | (1) Missing `~/.codex/` parent → `ENOENT` on promote, then mirror **wipes** runtime setting (test pre-creates dir and misses this). (2) `writeFileAtomically` without `mode` widens **0600 → 0644** on promote (reproduced). (3) Host-only promotion path: **WSL Codex never promotes** (WSL prep skips `syncSystemConfigIntoManagedCodexHome`). |
|
||||
| **#7658** | Recover WSL Codex usage RPC refresh | **P2** | Every WSL quota poll uses interactive login shell (`-ilc` + discovery chain); heavy rc cost on 15m poll / double on PTY fallback. |
|
||||
| **#7977** | Disable experimental new worktree card style by default | **P2** | Fresh default is correctly off, but cohort **auto-enabled in v1.4.130** stays on via persisted `true` (no rollback migration). |
|
||||
| **#7845** | Sidebar scrollbar only when overflow | **P2** (docs only) | Behavior OK; `docs/STYLEGUIDE.md` still says gutter is reserved — stale contract. |
|
||||
|
||||
### No issues found (clean for this scan)
|
||||
|
||||
| Ref | Title | Notes |
|
||||
|-----|-------|-------|
|
||||
| **#7860** | Separate pane identity from liveness (OMP tab flicker) | Identity vs liveness separation consistent across tab icon, PTY owner, web mirror; focused-pane owner re-owns Pi-compatible titles; no proven regression. |
|
||||
| **#7932** | Chunk offline audio decoding (ONNX SIGTRAP) | 30s chunk bound + quiet split; worker-side decode; pure chunker tests pass; no malware/native dep delta. Residual: no packaged ONNX runtime on all platforms. |
|
||||
| **#7933** | iOS native keyboard dictation in mobile terminal | Raw field echo; normalize only on send/mirror; no double-send proven. Mobile vitest not run (missing Expo deps in this worktree). |
|
||||
| `775fa956c` | Remove mobile driver overlay preview | Delete-only HTML mock. |
|
||||
| `8adfef4ff` | Update README downloads badge | `4.0m` → `4.1m` only. |
|
||||
| `ff661a7ab` / `41edc493b` / `6964bfb23` | release: v1.4.131-rc.{0,1,2} | Version bumps in `package.json` only. |
|
||||
|
||||
---
|
||||
|
||||
## Findings first
|
||||
|
||||
### P0 — Release Blocking
|
||||
|
||||
None.
|
||||
|
||||
No malware, backdoor, supply-chain implant, lockfile/dependency implant, install-hook change, or updater/signing bypass in this span.
|
||||
|
||||
### P1 — Releasable Only With Explicit Acceptance
|
||||
|
||||
1. **#7906 Mobile wake — unmounted non-passive sleep never gets a PTY**
|
||||
`wake-sleeping-agents-in-background.ts:43-56` only background-mounts for passive completed records; non-passive path creates inactive tab and clears the sleep record without a mounted `TerminalPane`.
|
||||
*Evidence:* `wake-sleeping-agents-in-background.ts`, `resume-sleeping-agent-session.ts:93-118`, `Terminal.tsx:729-778`.
|
||||
|
||||
2. **#7906 Mobile wake — double resume of same provider session**
|
||||
Broadcast in-place wake then generic resume for non-passive mounted records before spawn clears the record.
|
||||
*Evidence:* `wake-sleeping-agents-in-background.ts:37-56`, `pty-connection.ts:6247-6255`, `resume-sleeping-agent-session.ts:279-315`.
|
||||
|
||||
3. **#7906 Mobile wake — race during hibernation kill**
|
||||
Wake before `hibernatedWakePtyId` is armed is edge-triggered and not retried; passive path keeps record without launching.
|
||||
*Evidence:* `pty-connection.ts:2090-2169`, `orca-runtime.ts` fire-and-forget notifier.
|
||||
|
||||
4. **#7906 Perf — whole-worktree background mount**
|
||||
One passive sleep record mounts every terminal tab in the worktree permanently.
|
||||
*Evidence:* `wake-sleeping-agents-in-background.ts:46-54`, `background-terminal-worktree-visibility.ts`, `Terminal.tsx` mount model.
|
||||
|
||||
5. **#7906 Headless mobile pairing — wake no-op**
|
||||
No renderer ⇒ no wake; headless serve still returns activated.
|
||||
*Evidence:* `orca-runtime.ts:12627-12633`, `orca-runtime.test.ts` “deliberate non-goal”, `mobile/scripts/start-emulator-pairing-runtime.mjs`.
|
||||
|
||||
6. **#7960 Missing `~/.codex` directory loses first promoted setting**
|
||||
Promote does not `mkdir` system home; `ENOENT` swallowed; mirror then clears runtime value. Test pre-creates `.codex` and misses the case.
|
||||
*Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:6-14`, `config-settings-promotion.test.ts:190-191`. Reproduced: missing parent → `ENOENT`.
|
||||
|
||||
7. **#7960 Permission widening on promote (privacy)**
|
||||
Atomic write without mode: `0600` → `0644` under umask `0022` (reproduced). Full user config becomes world-readable to co-tenants when parent dirs are traversable.
|
||||
*Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:11-14`.
|
||||
|
||||
8. **#7960 WSL never gets promotion**
|
||||
WSL launch/rate-limit prep returns after `syncWslRuntimeForCurrentSelection` and never calls host mirror/promotion. Feature is host-only.
|
||||
*Evidence:* `runtime-home-service.ts:139-150,225-235`.
|
||||
|
||||
### P2 — Nice To Have
|
||||
|
||||
- **#7658** Interactive login shell on every WSL rate-limit poll (`buildWslLoginShellCommand` / `-ilc`).
|
||||
- **#7960** Unchanged baseline still rewritten twice per quota cycle; CRLF insert can mix EOL on new keys.
|
||||
- **#7977** Auto-enabled experimental card cohort not rolled back.
|
||||
- **#7845** STYLEGUIDE scrollbar gutter text out of date.
|
||||
|
||||
---
|
||||
|
||||
## Clean matrix
|
||||
|
||||
| Area | Status |
|
||||
|------|--------|
|
||||
| Security / malware / supply-chain | **Clean of malice.** Lockfile unchanged; package scripts unchanged; version-only package.json. **P1 residual:** mode widen on `~/.codex/config.toml` promote. |
|
||||
| Performance / resource use | **P1** mobile whole-worktree mount; **P2** baseline double-write + WSL interactive poll cost. STT chunking is an improvement. |
|
||||
| macOS / Linux / Windows / WSL / PowerShell | **P1** WSL promotion gap; **P2** WSL interactive poll; CRLF insert. No new hard-coded Mac shortcuts. Real Win/WSL runtime not executed. |
|
||||
| Mobile / paired web / Electron | **P1** mobile wake correctness/composition gaps; #7860 / #7933 clean by source audit. |
|
||||
| Local / SSH / remote / VM / container | Wake path failures apply to local and SSH worktrees once host renderer is involved. Headless serve gap explicit. |
|
||||
| Git providers / shared contracts | Not materially touched. |
|
||||
| Release / package / update path | Version bumps only; no workflow/signing/native rebuild changes. |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `git diff --check v1.4.130..v1.4.131-rc.2` | Passed |
|
||||
| Required security grep on non-lockfile span | 2 hits — both TOML `.exec` parsers in #7960; inspected, benign |
|
||||
| `package.json` / lockfile | Version field only; `pnpm-lock.yaml` no diff |
|
||||
| Mode repro (`writeFileSync` without mode) | `before 600` → `after 644` |
|
||||
| Missing parent repro | `ENOENT` |
|
||||
| Worker unit tests (various focused suites) | Reported green by workers (promotion, STT chunker, rate-limit, wake helpers, pane owner, etc.) |
|
||||
| Mobile Expo vitest / device / real WSL / ONNX packaged | **Not run** in this environment |
|
||||
|
||||
---
|
||||
|
||||
## Orchestration provenance
|
||||
|
||||
| Worker | Task | Terminal | Report |
|
||||
|--------|------|----------|--------|
|
||||
| W1 agent/mobile | `task_20d8fd2460cc` | `term_40e66a33-…` | `reports/w1-agent-mobile.md` |
|
||||
| W2 codex/speech/WSL | `task_6e280ce1760e` | `term_59f3ead8-…` | `reports/w2-codex-speech-wsl.md` |
|
||||
| W3 UI/defaults | `task_7140cb2014c9` | `term_4e274a95-…` | `reports/w3-ui-defaults.md` |
|
||||
| W4 xplat/remoting | `task_424816ffe823` | `term_03c7aac8-…` | `reports/w4-xplat-remoting.md` |
|
||||
| W5 perf | `task_1c5ac42d7659` | `term_24778551-…` | `reports/w5-perf.md` |
|
||||
| W6 security | `task_bf5de6f4913a` | `term_ae19a68d-…` | `reports/w6-security.md` |
|
||||
|
||||
All workers: `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"`, same worktree, `--inject` dispatch.
|
||||
|
||||
---
|
||||
|
||||
## Residual risk
|
||||
|
||||
- No runtime on physical iOS/Android, Windows+WSL2, or packaged sherpa/ONNX for all platforms.
|
||||
- Lockfile/binary deltas fully inspected: **none**. Install hooks unchanged.
|
||||
- #7906 integration of IPC wake × mounted/unmounted panes × async PTY exit is under-tested relative to risk.
|
||||
- #7960 Windows ACL inheritance and concurrent external `~/.codex` edits not runtime-tested; POSIX mode widen is proven.
|
||||
|
||||
---
|
||||
|
||||
## Bottom line
|
||||
|
||||
**No P0.** Multiple **P1**s cluster on **#7906 (mobile wake)** and **#7960 (Codex settings promotion)**. Ship only if product explicitly accepts those gaps, or fix before stable:
|
||||
|
||||
1. Always background-mount (or otherwise attach) when creating a navigation-suppressed resume tab.
|
||||
2. Deduplicate in-place hibernation wake vs generic resume for the same claim key.
|
||||
3. `mkdir` system `~/.codex` before promote; pass `mode: 0o600` (or preserve existing mode) into `writeFileAtomically`.
|
||||
4. Decide WSL + headless pairing product stance (implement promotion/wake or document as unsupported).
|
||||
|
||||
Clean shippable areas for this span: **#7860**, **#7932**, **#7933**, release version commits, README badge, preview delete; **#7845** functionally clean with docs P2.
|
||||
@@ -373,6 +373,40 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
expect(onRenamed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a failed upstream probe and retries instead of settling silently (issue #7808)', async () => {
|
||||
// Localized git (gettext + de_DE) translates even the `fatal:` prefix, so
|
||||
// the no-upstream error is unrecognizable. This must not settle as "has
|
||||
// upstream" — the badge goes up and a later event may still succeed.
|
||||
const localizedError = new Error(
|
||||
'Command failed: git rev-parse --abbrev-ref HEAD@{u}\n' +
|
||||
"Schwerwiegend: Kein Upstream-Branch für Branch 'you/Nautilus' konfiguriert."
|
||||
)
|
||||
const plainResponder = gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: false })
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'rev-parse' && args.some((arg) => arg.includes('@{u}'))) {
|
||||
throw localizedError
|
||||
}
|
||||
return plainResponder(args)
|
||||
})
|
||||
const { deps, onRenamed, setRenameError } = makeDeps()
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
expect(generateBranchNameMock).not.toHaveBeenCalled()
|
||||
expect(onRenamed).not.toHaveBeenCalled()
|
||||
expect(setRenameError).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
expect.stringContaining('Schwerwiegend')
|
||||
)
|
||||
|
||||
// Once git output is readable again, the same worktree retries and renames.
|
||||
gitExecFileAsyncMock.mockImplementation(plainResponder)
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['branch', '-m', 'you/fix-auth'],
|
||||
expect.objectContaining({ cwd: '/repo/wt' })
|
||||
)
|
||||
expect(setRenameError).toHaveBeenLastCalledWith(WORKTREE_ID, null)
|
||||
})
|
||||
|
||||
it('leaves ineligible branches untouched even when their leaf is a creature name', async () => {
|
||||
const { deps, onRenamed } = makeDeps({ canRenameOrcaCreatedBranch: () => false })
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
|
||||
@@ -18,7 +18,7 @@ import { gitExecFileAsync } from '../git/runner'
|
||||
import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import {
|
||||
branchHasUpstream,
|
||||
probeBranchUpstream,
|
||||
renameCurrentBranch,
|
||||
resolveUniqueBranchName,
|
||||
type GitExec
|
||||
@@ -207,9 +207,20 @@ async function runAutoRename(
|
||||
return stop(`branch "${currentBranch}" is not an auto-generated name`, true)
|
||||
}
|
||||
// Never rewrite a branch that's already been published.
|
||||
if (await branchHasUpstream(exec)) {
|
||||
const upstreamProbe = await probeBranchUpstream(exec)
|
||||
if (upstreamProbe.outcome === 'has-upstream') {
|
||||
return stop(`branch "${currentBranch}" already has an upstream`, true)
|
||||
}
|
||||
if (upstreamProbe.outcome === 'probe-failed') {
|
||||
// Why: settling an unreadable probe as "has upstream" made this failure
|
||||
// silent and permanent via the settled cache (issue #7808).
|
||||
const probeError = upstreamProbe.message.replace(/\s+/g, ' ').trim()
|
||||
deps.setRenameError(
|
||||
worktreeId,
|
||||
`Could not check whether the branch is published: ${probeError}`
|
||||
)
|
||||
return retry(`upstream probe failed: ${probeError}`)
|
||||
}
|
||||
|
||||
const settings = deps.getSettings()
|
||||
const hostKey = getCommitMessageModelDiscoveryHostKey(repo.connectionId ?? null)
|
||||
@@ -249,8 +260,13 @@ async function runAutoRename(
|
||||
if (branchNow !== currentBranch) {
|
||||
return retry(`branch changed during generation (${currentBranch} -> ${branchNow})`)
|
||||
}
|
||||
if (await branchHasUpstream(exec)) {
|
||||
return retry(`branch "${currentBranch}" was pushed during generation`)
|
||||
const postGenerationProbe = await probeBranchUpstream(exec)
|
||||
if (postGenerationProbe.outcome !== 'no-upstream') {
|
||||
return retry(
|
||||
postGenerationProbe.outcome === 'has-upstream'
|
||||
? `branch "${currentBranch}" was pushed during generation`
|
||||
: `upstream probe failed after generation: ${postGenerationProbe.message}`
|
||||
)
|
||||
}
|
||||
|
||||
const username = provider
|
||||
|
||||
@@ -253,6 +253,48 @@ describe('remote hook service installers', () => {
|
||||
expect(toml).toContain('trusted_hash = "sha256:')
|
||||
})
|
||||
|
||||
it('installs Codex hooks into an explicit redirected CODEX_HOME (WSL managed runtime home)', async () => {
|
||||
const runtimeHome = '/home/dev/.local/share/orca/codex-runtime-home/home'
|
||||
const { sftp, fs } = createFakeSftp({
|
||||
[`${runtimeHome}/config.toml`]: 'model = "gpt-5.2-codex"\n'
|
||||
})
|
||||
|
||||
const status = await new CodexHookService().installRemote(sftp, '/home/dev', {
|
||||
codexHomeDir: runtimeHome,
|
||||
deferTrustUntilConfigToml: true
|
||||
})
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.configPath).toBe(`${runtimeHome}/hooks.json`)
|
||||
expect(fs.files.has('/home/dev/.codex/hooks.json')).toBe(false)
|
||||
const hooks = JSON.parse(fs.files.get(`${runtimeHome}/hooks.json`)!) as {
|
||||
hooks: Record<string, { hooks: { command: string }[] }[]>
|
||||
}
|
||||
expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain(
|
||||
'/home/dev/.orca/agent-hooks/codex-hook.sh'
|
||||
)
|
||||
const toml = fs.files.get(`${runtimeHome}/config.toml`)
|
||||
expect(toml).toContain('model = "gpt-5.2-codex"')
|
||||
expect(toml).toContain(`${runtimeHome}/hooks.json:stop:0:0`)
|
||||
})
|
||||
|
||||
it('defers Codex trust writes until the redirected config.toml exists (launch-path seed race)', async () => {
|
||||
const runtimeHome = '/home/dev/.local/share/orca/codex-runtime-home/home'
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
|
||||
const status = await new CodexHookService().installRemote(sftp, '/home/dev', {
|
||||
codexHomeDir: runtimeHome,
|
||||
deferTrustUntilConfigToml: true
|
||||
})
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.detail).toContain('deferred')
|
||||
expect(fs.files.get(`${runtimeHome}/hooks.json`)).toContain('codex-hook.sh')
|
||||
// Why: creating config.toml here would make the launch path's
|
||||
// only-if-absent seed skip the user's real config.
|
||||
expect(fs.files.has(`${runtimeHome}/config.toml`)).toBe(false)
|
||||
})
|
||||
|
||||
it('reports Codex trust-write failures without rolling back installed hooks', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
fs.failRenameTo.add('/home/dev/.codex/config.toml')
|
||||
|
||||
@@ -15,15 +15,37 @@ import { hermesHookService } from '../hermes/hook-service'
|
||||
import { kimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
|
||||
export type RemoteManagedHookInstallOptions = {
|
||||
/** Explicit CODEX_HOME dir for redirected runtimes (WSL managed runtime
|
||||
* home). Codex-only: it is the one agent whose home Orca redirects. Also
|
||||
* defers the config.toml trust write until that file exists, so the
|
||||
* launch path's only-if-absent seed is never pre-empted. */
|
||||
codexHomeDir?: string
|
||||
}
|
||||
|
||||
type RemoteManagedHookInstaller = readonly [
|
||||
AgentHookInstallStatus['agent'],
|
||||
(sftp: SFTPWrapper, remoteHome: string) => Promise<AgentHookInstallStatus>
|
||||
(
|
||||
sftp: SFTPWrapper,
|
||||
remoteHome: string,
|
||||
options?: RemoteManagedHookInstallOptions
|
||||
) => Promise<AgentHookInstallStatus>
|
||||
]
|
||||
|
||||
const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
|
||||
['claude', (sftp, remoteHome) => claudeHookService.installRemote(sftp, remoteHome)],
|
||||
['openclaude', (sftp, remoteHome) => openClaudeHookService.installRemote(sftp, remoteHome)],
|
||||
['codex', (sftp, remoteHome) => codexHookService.installRemote(sftp, remoteHome)],
|
||||
[
|
||||
'codex',
|
||||
(sftp, remoteHome, options) =>
|
||||
codexHookService.installRemote(
|
||||
sftp,
|
||||
remoteHome,
|
||||
options?.codexHomeDir
|
||||
? { codexHomeDir: options.codexHomeDir, deferTrustUntilConfigToml: true }
|
||||
: undefined
|
||||
)
|
||||
],
|
||||
['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)],
|
||||
['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)],
|
||||
['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)],
|
||||
@@ -45,12 +67,13 @@ export const REMOTE_MANAGED_HOOK_INSTALLER_AGENTS: readonly AgentHookInstallStat
|
||||
|
||||
export async function installRemoteManagedAgentHooks(
|
||||
sftp: SFTPWrapper,
|
||||
remoteHome: string
|
||||
remoteHome: string,
|
||||
options?: RemoteManagedHookInstallOptions
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
const results: AgentHookInstallStatus[] = []
|
||||
for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) {
|
||||
try {
|
||||
const result = await install(sftp, remoteHome)
|
||||
const result = await install(sftp, remoteHome, options)
|
||||
results.push(result)
|
||||
if (result.state === 'error') {
|
||||
console.warn(
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// SFTP-shaped adapter over the WSL hook relay's fs bridge. Lets the
|
||||
// unchanged SSH remote hook installers (`installRemoteManagedAgentHooks`)
|
||||
// write into a WSL distro's home over the relay's already-open stdio channel
|
||||
// — the WSL twin of the SSH flow's real SFTPWrapper. Only the primitives
|
||||
// `installer-utils-remote.ts` touches are implemented.
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
|
||||
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
|
||||
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract'
|
||||
|
||||
/** Run the shared remote hook installers against a WSL guest over the relay's
|
||||
* fs bridge. Codex is the one agent whose home Orca redirects for WSL
|
||||
* sessions, so its hooks go to the managed runtime home. */
|
||||
export async function installWslGuestHooks(options: {
|
||||
mux: SshChannelMultiplexer
|
||||
guestHome: string
|
||||
distro: string
|
||||
installHooks: typeof installRemoteManagedAgentHooks
|
||||
warn: (message: string) => void
|
||||
}): Promise<void> {
|
||||
const { mux, guestHome, distro, installHooks, warn } = options
|
||||
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
|
||||
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome)
|
||||
})
|
||||
const failed = results.filter((r) => r.state === 'error').length
|
||||
if (failed > 0) {
|
||||
warn(
|
||||
`[agent-hooks] WSL hook install for '${distro}': ${failed}/${results.length} agents failed`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type SftpCallback<T = void> = (err: Error | null, value?: T) => void
|
||||
|
||||
// Why: installer-utils-remote classifies errors by ssh2's numeric SFTP status
|
||||
// codes (ENOENT=2, already-exists=4). Map guest POSIX errno onto those so the
|
||||
// shared classifiers keep working across transports.
|
||||
const ERRNO_TO_SFTP_CODE: Record<string, number> = {
|
||||
ENOENT: 2,
|
||||
ENOTDIR: 2,
|
||||
EACCES: 3,
|
||||
EEXIST: 4
|
||||
}
|
||||
|
||||
function toSftpError(failure: { errno?: string; message?: string }): Error {
|
||||
const err = new Error(failure.message ?? 'wsl fs bridge failure') as Error & { code?: number }
|
||||
err.code = ERRNO_TO_SFTP_CODE[failure.errno ?? ''] ?? 5
|
||||
return err
|
||||
}
|
||||
|
||||
export function createWslHookSftpAdapter(mux: SshChannelMultiplexer): SFTPWrapper {
|
||||
const call = <Wire extends object, Value>(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
callback: SftpCallback<Value>,
|
||||
pick: (result: { ok: true } & Wire) => Value
|
||||
): void => {
|
||||
mux
|
||||
.request(method, params)
|
||||
.then((raw) => {
|
||||
const result = raw as WslFsResult<Wire>
|
||||
if (!result || typeof result !== 'object' || result.ok !== true) {
|
||||
callback(toSftpError((result ?? {}) as { errno?: string; message?: string }))
|
||||
return
|
||||
}
|
||||
callback(null, pick(result))
|
||||
})
|
||||
.catch((err) => callback(err instanceof Error ? err : new Error(String(err))))
|
||||
}
|
||||
const callVoid = (
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
callback: SftpCallback
|
||||
): void => {
|
||||
call<Record<string, never>, undefined>(method, params, callback, () => undefined)
|
||||
}
|
||||
|
||||
const adapter = {
|
||||
readFile(path: string, _encoding: unknown, callback: SftpCallback<string>): void {
|
||||
call<{ content: string }, string>(
|
||||
WSL_HOOK_FS_METHODS.readFile,
|
||||
{ path },
|
||||
callback,
|
||||
(r) => r.content
|
||||
)
|
||||
},
|
||||
writeFile(
|
||||
path: string,
|
||||
content: string,
|
||||
options: { mode?: number },
|
||||
callback: SftpCallback
|
||||
): void {
|
||||
callVoid(WSL_HOOK_FS_METHODS.writeFile, { path, content, mode: options?.mode }, callback)
|
||||
},
|
||||
stat(path: string, callback: SftpCallback<{ mode: number }>): void {
|
||||
call<{ mode: number }, { mode: number }>(
|
||||
WSL_HOOK_FS_METHODS.stat,
|
||||
{ path },
|
||||
callback,
|
||||
(r) => ({
|
||||
mode: r.mode
|
||||
})
|
||||
)
|
||||
},
|
||||
// Why: POSIX rename overwrites atomically, which is exactly the OpenSSH
|
||||
// overwrite-rename semantics the installers prefer — so the extension is
|
||||
// "supported" here and plain rename shares the implementation.
|
||||
ext_openssh_rename(src: string, dst: string, callback: SftpCallback): void {
|
||||
callVoid(WSL_HOOK_FS_METHODS.rename, { src, dst }, callback)
|
||||
},
|
||||
rename(src: string, dst: string, callback: SftpCallback): void {
|
||||
callVoid(WSL_HOOK_FS_METHODS.rename, { src, dst }, callback)
|
||||
},
|
||||
unlink(path: string, callback: SftpCallback): void {
|
||||
callVoid(WSL_HOOK_FS_METHODS.unlink, { path }, callback)
|
||||
},
|
||||
chmod(path: string, mode: number, callback: SftpCallback): void {
|
||||
callVoid(WSL_HOOK_FS_METHODS.chmod, { path, mode }, callback)
|
||||
},
|
||||
readdir(path: string, callback: SftpCallback<{ filename: string }[]>): void {
|
||||
call<{ entries: { filename: string }[] }, { filename: string }[]>(
|
||||
WSL_HOOK_FS_METHODS.readdir,
|
||||
{ path },
|
||||
callback,
|
||||
(r) => r.entries
|
||||
)
|
||||
},
|
||||
mkdir(path: string, callback: SftpCallback): void {
|
||||
callVoid(WSL_HOOK_FS_METHODS.mkdir, { path }, callback)
|
||||
}
|
||||
}
|
||||
|
||||
return adapter as unknown as SFTPWrapper
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// DI seam for WslHookRelayManager: the full dependency contract plus the
|
||||
// production wiring. Tests construct the manager with fakes for everything
|
||||
// that spawns wsl.exe or touches the live agentHookServer.
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { agentHookServer } from './server'
|
||||
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import {
|
||||
isWslDistroRunning,
|
||||
resolveWslHookRelayBundle,
|
||||
runWslInstallProcess,
|
||||
spawnWslRelayProcess
|
||||
} from './wsl-hook-relay-launch'
|
||||
import { waitForWslRelaySentinel } from './wsl-hook-relay-sentinel'
|
||||
import { listWslDistrosAsync } from '../wsl'
|
||||
import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay'
|
||||
|
||||
// Why: fresh WSL intermittently throws "Catastrophic failure (E_UNEXPECTED)"
|
||||
// under concurrent wsl.exe spawn load; the retry pause is a dep so tests can
|
||||
// collapse it.
|
||||
export const WSL_RELAY_TRANSIENT_RETRY_DELAY_MS = 2_000
|
||||
|
||||
// Restart/cooldown policy for the manager's state machine.
|
||||
export const FAILURE_COOLDOWN_BASE_MS = 60_000
|
||||
export const FAILURE_COOLDOWN_MAX_MS = 10 * 60_000
|
||||
// Why: a distro without node >= 18 will not grow one mid-session; probe
|
||||
// rarely instead of once per PTY spawn.
|
||||
export const NO_NODE_COOLDOWN_MS = 10 * 60_000
|
||||
// Why: a previously-healthy relay dying mid-session (mux protocol error, WSL
|
||||
// restart) must self-recover — a live agent session produces no new PTY
|
||||
// spawns, so waiting for the next ensure would leave status dead for good.
|
||||
export const RUNNING_TEARDOWN_COOLDOWN_MS = 10_000
|
||||
// Why: only a relay that stayed up this long resets the failure counter — a
|
||||
// connect-then-crash loop must keep escalating its cooldown, not sit at the
|
||||
// running-teardown base forever.
|
||||
export const STABLE_UPTIME_MS = 2 * 60_000
|
||||
// Why: re-running the (byte-equality idempotent) installers picks up configs
|
||||
// that appear after first install — e.g. Codex's runtime-home config.toml is
|
||||
// seeded by the launch path, so its hook-trust entries can only be written
|
||||
// once that file exists. The one-shot timer covers single-spawn sessions.
|
||||
export const REINSTALL_MIN_INTERVAL_MS = 30_000
|
||||
export const REINSTALL_ONE_SHOT_DELAY_MS = 60_000
|
||||
|
||||
export type WslHookRelayManagerDeps = {
|
||||
platform: () => NodeJS.Platform
|
||||
remoteHooksEnabled: () => boolean
|
||||
hookCoordsEnv: () => Record<string, string>
|
||||
/** Restart-stable, instance-unique key for the guest endpoint dir. */
|
||||
instanceKey: () => string | null
|
||||
resolveBundle: typeof resolveWslHookRelayBundle
|
||||
readBundle: (jsPath: string) => Buffer
|
||||
listDistros: () => Promise<string[]>
|
||||
isDistroRunning: typeof isWslDistroRunning
|
||||
spawnRelay: typeof spawnWslRelayProcess
|
||||
runInstall: typeof runWslInstallProcess
|
||||
waitForSentinel: typeof waitForWslRelaySentinel
|
||||
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
|
||||
installHooks: typeof installRemoteManagedAgentHooks
|
||||
warn: (message: string) => void
|
||||
transientRetryDelayMs: number
|
||||
}
|
||||
|
||||
export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
|
||||
platform: () => process.platform,
|
||||
remoteHooksEnabled: () => isRemoteAgentHooksEnabled(),
|
||||
hookCoordsEnv: () => agentHookServer.buildPtyEnv(),
|
||||
// Why: the Windows endpoint file path (userData + namespace) is stable
|
||||
// across app restarts and distinct per instance — exactly the identity the
|
||||
// guest endpoint dir must carry so surviving agents re-coordinate.
|
||||
instanceKey: () => {
|
||||
const source = agentHookServer.endpointFilePath
|
||||
return source ? createHash('sha256').update(source).digest('hex').slice(0, 12) : null
|
||||
},
|
||||
resolveBundle: resolveWslHookRelayBundle,
|
||||
readBundle: (jsPath) => readFileSync(jsPath),
|
||||
listDistros: () => listWslDistrosAsync(),
|
||||
isDistroRunning: isWslDistroRunning,
|
||||
spawnRelay: spawnWslRelayProcess,
|
||||
runInstall: runWslInstallProcess,
|
||||
waitForSentinel: waitForWslRelaySentinel,
|
||||
ingest: (envelope, connectionId) =>
|
||||
agentHookServer.ingestRemote(
|
||||
envelope as Parameters<typeof agentHookServer.ingestRemote>[0],
|
||||
connectionId
|
||||
),
|
||||
installHooks: installRemoteManagedAgentHooks,
|
||||
warn: (message) => console.warn(message),
|
||||
transientRetryDelayMs: WSL_RELAY_TRANSIENT_RETRY_DELAY_MS
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// Launch/install plumbing for the guest-resident WSL agent-hook relay:
|
||||
// bundle resolution on the Windows side, the guest launch/install scripts,
|
||||
// and the sentinel wait that turns a wsl.exe child's stdio into a
|
||||
// MultiplexerTransport. Kept separate from the manager so the state machine
|
||||
// stays readable. See docs/agent-status-over-wsl.md (STA-1515).
|
||||
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
|
||||
import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
|
||||
import {
|
||||
decodeWslText,
|
||||
MAX_STARTUP_BUFFER_BYTES,
|
||||
type waitForWslRelaySentinel,
|
||||
type WslRelayStartupFailure
|
||||
} from './wsl-hook-relay-sentinel'
|
||||
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
|
||||
import {
|
||||
WSL_HOOK_RELAY_BUNDLE_NAME,
|
||||
WSL_HOOK_RELAY_DIR,
|
||||
WSL_HOOK_RELAY_INSTANCE_ENV,
|
||||
WSL_HOOK_RELAY_NO_NODE_EXIT_CODE,
|
||||
WSL_HOOK_RELAY_STALE_EXIT_CODE,
|
||||
WSL_HOOK_RELAY_VERSION_ENV,
|
||||
WSL_HOOK_RELAY_VERSION_FILE
|
||||
} from '../../shared/wsl-hook-relay-contract'
|
||||
|
||||
const INSTALL_TIMEOUT_MS = 30_000
|
||||
|
||||
export type WslHookRelayBundle = { jsPath: string; version: string }
|
||||
|
||||
export function resolveWslHookRelayBundle(): WslHookRelayBundle | null {
|
||||
// Mirrors getLocalRelayCandidates in ssh-relay-deploy: env override for
|
||||
// tests/dev, then packaged extraResources, then dev out/ paths.
|
||||
const candidates: string[] = []
|
||||
if (process.env.ORCA_RELAY_PATH) {
|
||||
candidates.push(join(process.env.ORCA_RELAY_PATH, 'wsl'))
|
||||
}
|
||||
if (process.resourcesPath) {
|
||||
candidates.push(join(process.resourcesPath, 'relay', 'wsl'))
|
||||
candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', 'wsl'))
|
||||
}
|
||||
try {
|
||||
const appPath = app.getAppPath()
|
||||
candidates.push(join(appPath, 'resources', 'relay', 'wsl'))
|
||||
candidates.push(join(appPath, 'out', 'relay', 'wsl'))
|
||||
} catch {
|
||||
// app not ready in some test contexts — env/resources candidates suffice.
|
||||
}
|
||||
for (const dir of candidates) {
|
||||
const jsPath = join(dir, WSL_HOOK_RELAY_BUNDLE_NAME)
|
||||
const versionPath = join(dir, WSL_HOOK_RELAY_VERSION_FILE)
|
||||
if (existsSync(jsPath) && existsSync(versionPath)) {
|
||||
const version = readFileSync(versionPath, 'utf8').trim()
|
||||
// Why: the version lands inside single-quoted guest shell text and in
|
||||
// a guest path segment — refuse anything outside the safe alphabet.
|
||||
if (/^[A-Za-z0-9+.-]+$/.test(version)) {
|
||||
return { jsPath, version }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: the install dir is namespaced by bundle version so concurrent Orca
|
||||
// instances with different bundles (dev + prod) never reinstall over each
|
||||
// other; each instance launches exactly the version it shipped.
|
||||
function guestRelayDirExpr(version: string): string {
|
||||
return `$HOME/${WSL_HOOK_RELAY_DIR}/${version}`
|
||||
}
|
||||
|
||||
/** Guest launcher, installed alongside the bundle. The `.version` marker is
|
||||
* written last by the installer, so the check rejects partial installs;
|
||||
* node resolution probes each candidate's version because `sh -c` does not
|
||||
* source interactive profiles (an apt node 12 on PATH must not shadow an
|
||||
* nvm node 20 off PATH). */
|
||||
export function buildGuestLaunchScript(version: string): string {
|
||||
const dir = guestRelayDirExpr(version)
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
`d="${dir}"`,
|
||||
`v="$(cat "$d/${WSL_HOOK_RELAY_VERSION_FILE}" 2>/dev/null || true)"`,
|
||||
`[ -n "$${WSL_HOOK_RELAY_VERSION_ENV}" ] && [ "$v" = "$${WSL_HOOK_RELAY_VERSION_ENV}" ] || exit ${WSL_HOOK_RELAY_STALE_EXIT_CODE}`,
|
||||
'n=""',
|
||||
'for c in "$(command -v node 2>/dev/null || true)" "$HOME/.nvm/versions/node"/*/bin/node /usr/local/bin/node /usr/bin/node "$HOME/.local/bin/node"; do',
|
||||
' [ -n "$c" ] && [ -x "$c" ] || continue',
|
||||
` if "$c" -e 'process.exit(Number(process.versions.node.split(".")[0])>=18?0:1)' 2>/dev/null; then`,
|
||||
' n="$c"',
|
||||
' break',
|
||||
' fi',
|
||||
'done',
|
||||
`[ -n "$n" ] || exit ${WSL_HOOK_RELAY_NO_NODE_EXIT_CODE}`,
|
||||
`exec "$n" "$d/${WSL_HOOK_RELAY_BUNDLE_NAME}"`,
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Idempotent install script, piped to `sh -s` over stdin. Heredocs with
|
||||
* quoted delimiters carry the bundle (base64) and launcher verbatim, so no
|
||||
* argv quoting crosses the wsl.exe boundary. Tmp names carry the guest PID
|
||||
* so same-version concurrent installs cannot corrupt each other. */
|
||||
export function buildGuestInstallScript(bundleJs: Buffer, version: string): string {
|
||||
const b64 = bundleJs.toString('base64').replace(/(.{1,120})/g, '$1\n')
|
||||
return [
|
||||
'set -e',
|
||||
'umask 077',
|
||||
`d="${guestRelayDirExpr(version)}"`,
|
||||
'mkdir -p "$d"',
|
||||
`base64 -d > "$d/bundle.$$.tmp" << 'ORCA_EOF_BUNDLE'`,
|
||||
b64.trimEnd(),
|
||||
'ORCA_EOF_BUNDLE',
|
||||
`mv "$d/bundle.$$.tmp" "$d/${WSL_HOOK_RELAY_BUNDLE_NAME}"`,
|
||||
`cat > "$d/launch.$$.tmp" << 'ORCA_EOF_LAUNCH'`,
|
||||
buildGuestLaunchScript(version).trimEnd(),
|
||||
'ORCA_EOF_LAUNCH',
|
||||
'mv "$d/launch.$$.tmp" "$d/launch.sh"',
|
||||
'chmod 700 "$d/launch.sh"',
|
||||
// Version marker last: a partial install stays "stale" and reinstalls.
|
||||
`printf '%s' '${version}' > "$d/${WSL_HOOK_RELAY_VERSION_FILE}"`,
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function spawnWslRelayProcess(
|
||||
distro: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
version: string
|
||||
): ChildProcessWithoutNullStreams {
|
||||
// Why: --exec bypasses the distro's default login shell — a bare `--`
|
||||
// routes through it (a fish/nushell chsh could mangle the command) and
|
||||
// triggers wsl.exe's `$`-preprocessing of Windows argv. --exec passes argv
|
||||
// verbatim (same form as the Codex WSL login spawn), so `$HOME` reaches
|
||||
// sh unescaped and expands guest-side.
|
||||
const command = `exec sh "${guestRelayDirExpr(version)}/launch.sh"`
|
||||
return spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-c', command], {
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
}
|
||||
|
||||
/** True when the distro shows in `wsl --list --running`. Listing does NOT
|
||||
* boot anything — unlike `wsl -d`, which starts a stopped distro. The
|
||||
* restart timer must check this so relay recovery never resurrects a VM the
|
||||
* user shut down with `wsl --shutdown`. Fails CLOSED (false) on probe
|
||||
* errors: booting a VM the user shut down is worse than a skipped restart
|
||||
* (the next WSL PTY spawn re-ensures), and a wsl.exe too wedged to list
|
||||
* distros would not have launched the relay anyway. */
|
||||
export function isWslDistroRunning(distro: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
'wsl.exe',
|
||||
['--list', '--running', '--quiet'],
|
||||
// Why: WSL_UTF8=1 forces UTF-8 output; without it wsl.exe emits
|
||||
// UTF-16LE that reads as NUL-riddled text.
|
||||
{ env: { ...process.env, WSL_UTF8: '1' }, timeout: 10_000, windowsHide: true },
|
||||
(err, stdout) => {
|
||||
if (err) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
const wanted = distro.trim().toLowerCase()
|
||||
const running = decodeWslText(String(stdout))
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
resolve(running.includes(wanted))
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function runWslInstallProcess(
|
||||
distro: string,
|
||||
script: string,
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<{ code: number | null; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Why: --exec skips the default login shell; the script rides stdin so
|
||||
// no quoting crosses the wsl.exe boundary at all.
|
||||
const child = spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-s'], {
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
// Why: a wedged wsl.exe here would otherwise pin the manager's state at
|
||||
// 'starting' forever — the one unbounded await on the ensure path.
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
child.kill()
|
||||
resolve({
|
||||
code: null,
|
||||
stderr: `${stderr}\ninstall timed out after ${INSTALL_TIMEOUT_MS}ms`
|
||||
})
|
||||
}
|
||||
}, INSTALL_TIMEOUT_MS)
|
||||
child.stderr.on('data', (d: Buffer) => {
|
||||
stderr = (stderr + decodeWslText(d.toString('utf8'))).slice(-MAX_STARTUP_BUFFER_BYTES)
|
||||
})
|
||||
child.on('error', (err) => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
resolve({ code, stderr })
|
||||
}
|
||||
})
|
||||
child.stdin.on('error', () => {
|
||||
// Guest exited before consuming stdin — surfaced via close/code.
|
||||
})
|
||||
child.stdin.write(script)
|
||||
child.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
const TRANSIENT_RETRY_LIMIT = 2
|
||||
|
||||
export type WslRelayLaunchIo = {
|
||||
spawnRelay: typeof spawnWslRelayProcess
|
||||
waitForSentinel: typeof waitForWslRelaySentinel
|
||||
runInstall: typeof runWslInstallProcess
|
||||
readBundle: (jsPath: string) => Buffer
|
||||
transientRetryDelayMs: number
|
||||
}
|
||||
|
||||
/** Spawn → sentinel → connect, with the guest-install/retry policy: stale or
|
||||
* missing installs get exactly one streamed reinstall, wsl.exe's transient
|
||||
* "Catastrophic failure (E_UNEXPECTED)" gets a bounded retry, a distro
|
||||
* without node >= 18 reports through `onNoNode`. Terminal failures report
|
||||
* through `onFailure`; non-startup errors propagate to the caller. */
|
||||
export async function launchWslRelayWithInstall(options: {
|
||||
distro: string
|
||||
env: NodeJS.ProcessEnv
|
||||
bundleJsPath: string
|
||||
version: string
|
||||
io: WslRelayLaunchIo
|
||||
isDisposed: () => boolean
|
||||
onChild: (child: ChildProcessWithoutNullStreams) => void
|
||||
onNoNode: () => void
|
||||
onFailure: (message: string) => void
|
||||
connect: (transport: MultiplexerTransport, child: ChildProcessWithoutNullStreams) => Promise<void>
|
||||
}): Promise<void> {
|
||||
const { distro, env, bundleJsPath, version, io } = options
|
||||
let installTried = false
|
||||
let transientRetries = 0
|
||||
for (;;) {
|
||||
if (options.isDisposed()) {
|
||||
return
|
||||
}
|
||||
const child = io.spawnRelay(distro, env, version)
|
||||
options.onChild(child)
|
||||
try {
|
||||
const transport = await io.waitForSentinel(child)
|
||||
await options.connect(transport, child)
|
||||
return
|
||||
} catch (err) {
|
||||
const failure = (err as { startup?: WslRelayStartupFailure }).startup
|
||||
if (!failure) {
|
||||
throw err
|
||||
}
|
||||
if (failure.code === WSL_HOOK_RELAY_NO_NODE_EXIT_CODE) {
|
||||
options.onNoNode()
|
||||
return
|
||||
}
|
||||
if (
|
||||
/catastrophic failure/i.test(failure.stderr) &&
|
||||
transientRetries < TRANSIENT_RETRY_LIMIT
|
||||
) {
|
||||
transientRetries++
|
||||
await new Promise((resolve) => setTimeout(resolve, io.transientRetryDelayMs))
|
||||
continue
|
||||
}
|
||||
if (!installTried) {
|
||||
installTried = true
|
||||
const script = buildGuestInstallScript(io.readBundle(bundleJsPath), version)
|
||||
const result = await io.runInstall(distro, script, env)
|
||||
if (result.code === 0) {
|
||||
continue
|
||||
}
|
||||
options.onFailure(
|
||||
`guest install failed (code ${result.code ?? 'unknown'}): ${result.stderr.trim()}`
|
||||
)
|
||||
return
|
||||
}
|
||||
options.onFailure(formatWslRelayFailure(failure))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatWslRelayFailure(failure: WslRelayStartupFailure): string {
|
||||
const detail = failure.stderr.trim()
|
||||
return `startup failed (${failure.kind}, code ${failure.code ?? 'unknown'})${detail ? `: ${detail}` : ''}`
|
||||
}
|
||||
|
||||
/** Env for the relay's wsl.exe spawn: the live hook coordinates, the
|
||||
* host-expected bundle version, and the stable instance key, all crossed
|
||||
* via WSLENV. WSL_UTF8 keeps wsl.exe's own error text (e.g. "Catastrophic
|
||||
* failure") UTF-8 so stderr matching and breadcrumbs stay readable. */
|
||||
export function buildWslRelaySpawnEnv(
|
||||
coords: Record<string, string>,
|
||||
bundleVersion: string,
|
||||
instanceKey: string
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
WSL_UTF8: '1',
|
||||
ORCA_AGENT_HOOK_PORT: coords.ORCA_AGENT_HOOK_PORT,
|
||||
ORCA_AGENT_HOOK_TOKEN: coords.ORCA_AGENT_HOOK_TOKEN,
|
||||
ORCA_AGENT_HOOK_ENV: coords.ORCA_AGENT_HOOK_ENV,
|
||||
ORCA_AGENT_HOOK_VERSION: coords.ORCA_AGENT_HOOK_VERSION,
|
||||
[WSL_HOOK_RELAY_VERSION_ENV]: bundleVersion,
|
||||
[WSL_HOOK_RELAY_INSTANCE_ENV]: instanceKey
|
||||
}
|
||||
// Why: the relay derives its own guest endpoint path; a /p-translated
|
||||
// Windows endpoint here would only add WSLENV noise.
|
||||
delete env.ORCA_AGENT_HOOK_ENDPOINT
|
||||
addOrcaWslInteropEnv(env as Record<string, string>)
|
||||
return env
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Envelope forwarding and death handling for one live WSL relay link.
|
||||
// Extracted from the manager so its state machine stays readable; the manager
|
||||
// decides what a dead link means (cooldown, restart), this module guarantees
|
||||
// it finds out exactly once, whichever signal fires first.
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { AGENT_HOOK_NOTIFICATION_METHOD } from '../../shared/agent-hook-relay'
|
||||
import { wslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract'
|
||||
|
||||
export type WslRelayLinkOptions = {
|
||||
mux: SshChannelMultiplexer
|
||||
child: ChildProcessWithoutNullStreams
|
||||
distro: string
|
||||
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
|
||||
warn: (message: string) => void
|
||||
/** Called exactly once when the link dies — from EITHER a mux dispose
|
||||
* (protocol error, keepalive timeout) or the child exiting. A mux death
|
||||
* without child death would otherwise blackhole every later envelope
|
||||
* while the guest keeps returning 204 to hook clients. */
|
||||
onDead: (reason: string) => void
|
||||
}
|
||||
|
||||
export function wireWslRelayLink(options: WslRelayLinkOptions): void {
|
||||
const { mux, child, distro, ingest, warn, onDead } = options
|
||||
const connectionId = wslHookRelayConnectionId(distro)
|
||||
|
||||
mux.onNotification((method, params) => {
|
||||
if (method !== AGENT_HOOK_NOTIFICATION_METHOD) {
|
||||
return
|
||||
}
|
||||
if (typeof (params as { paneKey?: unknown }).paneKey !== 'string') {
|
||||
return
|
||||
}
|
||||
if (process.env.ORCA_WSL_HOOK_RELAY_DEBUG === '1') {
|
||||
const p = params as { paneKey?: string; payload?: { state?: string } }
|
||||
warn(
|
||||
`[agent-hooks] WSL relay envelope (${distro}): pane=${p.paneKey} state=${p.payload?.state ?? '?'}`
|
||||
)
|
||||
}
|
||||
// Trust boundary: ingestRemote re-validates paneKey/tabId and
|
||||
// re-normalizes the payload, same as the SSH relay path.
|
||||
ingest(params, connectionId)
|
||||
})
|
||||
|
||||
let dead = false
|
||||
const die = (reason: string): void => {
|
||||
if (dead) {
|
||||
return
|
||||
}
|
||||
dead = true
|
||||
mux.dispose()
|
||||
child.kill()
|
||||
onDead(reason)
|
||||
}
|
||||
mux.onDispose((reason) => die(`mux disposed (${reason})`))
|
||||
child.on('close', () => die('process exited'))
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Live end-to-end oracle for the WSL hook relay HOST side: the real esbuild
|
||||
// bundle runs as a real child process (spawned via `node` instead of wsl.exe
|
||||
// — everything else identical), the real manager connects over the child's
|
||||
// actual stdio pipes, the real installers write through the fs bridge, and a
|
||||
// real HTTP POST in the exact Claude hook shape must land in a real
|
||||
// AgentHookServer.ingestRemote. This is the chain the Windows-rig GUI run
|
||||
// exercises minus the wsl.exe byte transport (validated separately on-rig).
|
||||
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer } from 'node:net'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AgentHookServer } from './server'
|
||||
import { WslHookRelayManager } from './wsl-hook-relay-manager'
|
||||
|
||||
const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl')
|
||||
const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js')
|
||||
const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
async function pickFreePort(): Promise<number> {
|
||||
const probe = createServer()
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const address = probe.address()
|
||||
resolve(typeof address === 'object' && address ? address.port : 0)
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()))
|
||||
return port
|
||||
}
|
||||
|
||||
// Why skipIf: the guest is always POSIX — the bundle derives its home from
|
||||
// $HOME, which os.homedir() ignores on Windows. Windows coverage comes from
|
||||
// the live rig runs against a real distro.
|
||||
describe.skipIf(process.platform === 'win32')(
|
||||
'WSL hook relay live host chain (real bundle over real child stdio)',
|
||||
() => {
|
||||
let fakeHome: string
|
||||
let manager: WslHookRelayManager | null
|
||||
let orcaServer: AgentHookServer | null
|
||||
let child: ChildProcessWithoutNullStreams | null
|
||||
|
||||
beforeAll(() => {
|
||||
if (!existsSync(BUNDLE_JS)) {
|
||||
execFileSync(process.execPath, [join('config', 'scripts', 'build-relay.mjs')], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'ignore'
|
||||
})
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
afterEach(() => {
|
||||
manager?.disposeAll()
|
||||
orcaServer?.stop()
|
||||
child?.kill()
|
||||
rmSync(fakeHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('delivers a Claude hook POST from the live relay into ingestRemote and installs guest hooks', async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), 'wsl-live-home-'))
|
||||
const preferredPort = await pickFreePort()
|
||||
const version = readFileSync(join(BUNDLE_DIR, '.version'), 'utf8').trim()
|
||||
|
||||
orcaServer = new AgentHookServer()
|
||||
const events: { paneKey: string; payload: unknown; connectionId: string | null }[] = []
|
||||
orcaServer.setListener((event) => {
|
||||
events.push({
|
||||
paneKey: event.paneKey,
|
||||
payload: event.payload,
|
||||
connectionId: event.connectionId
|
||||
})
|
||||
})
|
||||
const server = orcaServer
|
||||
|
||||
const warns: string[] = []
|
||||
manager = new WslHookRelayManager({
|
||||
platform: () => 'win32',
|
||||
remoteHooksEnabled: () => true,
|
||||
hookCoordsEnv: () => ({
|
||||
ORCA_AGENT_HOOK_PORT: String(preferredPort),
|
||||
ORCA_AGENT_HOOK_TOKEN: 'live-token',
|
||||
ORCA_AGENT_HOOK_ENV: 'production',
|
||||
ORCA_AGENT_HOOK_VERSION: '1'
|
||||
}),
|
||||
instanceKey: () => 'liveinstance',
|
||||
resolveBundle: () => ({ jsPath: BUNDLE_JS, version }),
|
||||
listDistros: async () => ['LiveDistro'],
|
||||
spawnRelay: (_distro, env) => {
|
||||
child = spawn(process.execPath, [BUNDLE_JS], {
|
||||
env: { ...env, HOME: fakeHome },
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}) as ChildProcessWithoutNullStreams
|
||||
return child
|
||||
},
|
||||
runInstall: async () => {
|
||||
throw new Error('guest install must not run for a direct node spawn')
|
||||
},
|
||||
ingest: (envelope, connectionId) =>
|
||||
server.ingestRemote(
|
||||
envelope as Parameters<AgentHookServer['ingestRemote']>[0],
|
||||
connectionId
|
||||
),
|
||||
warn: (message) => warns.push(message),
|
||||
transientRetryDelayMs: 1
|
||||
})
|
||||
|
||||
manager.ensureForDistro('LiveDistro')
|
||||
|
||||
// Codex hooks land in the redirected managed runtime home. Waiting on
|
||||
// this artifact (not Claude's, which is written first) keeps the
|
||||
// assertions behind the still-running 14-agent installer loop.
|
||||
const codexRuntimeHome = join(
|
||||
fakeHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
await vi.waitFor(() => expect(existsSync(join(codexRuntimeHome, 'hooks.json'))).toBe(true), {
|
||||
timeout: 15_000
|
||||
})
|
||||
expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true)
|
||||
const claudeScript = readFileSync(
|
||||
join(fakeHome, '.orca', 'agent-hooks', 'claude-hook.sh'),
|
||||
'utf8'
|
||||
)
|
||||
expect(claudeScript).toContain('/hook/claude')
|
||||
|
||||
// Trust TOML is deferred so the launch-path seed is never pre-empted.
|
||||
expect(existsSync(join(codexRuntimeHome, 'config.toml'))).toBe(false)
|
||||
expect(existsSync(join(fakeHome, '.codex', 'hooks.json'))).toBe(false)
|
||||
|
||||
// Re-coordinate exactly like a hook script: read the relay-written
|
||||
// endpoint file rather than assuming the preferred port bind won.
|
||||
const endpointFile = join(
|
||||
fakeHome,
|
||||
'.orca-wsl',
|
||||
'agent-hooks',
|
||||
'instance-liveinstance',
|
||||
'endpoint.env'
|
||||
)
|
||||
expect(existsSync(endpointFile)).toBe(true)
|
||||
const endpointText = readFileSync(endpointFile, 'utf8')
|
||||
const port = Number(/ORCA_AGENT_HOOK_PORT=['"]?(\d+)/.exec(endpointText)?.[1])
|
||||
const token = /ORCA_AGENT_HOOK_TOKEN=['"]?([A-Za-z0-9-]+)/.exec(endpointText)?.[1]
|
||||
expect(port).toBeGreaterThan(0)
|
||||
expect(token).toBe('live-token')
|
||||
|
||||
const paneKey = `tab-live:${LEAF}`
|
||||
const postClaude = async (payload: Record<string, unknown>): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token ?? ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey,
|
||||
tabId: 'tab-live',
|
||||
worktreeId: 'wt-live',
|
||||
env: 'remote',
|
||||
version: '1',
|
||||
payload
|
||||
})
|
||||
})
|
||||
|
||||
const promptRes = await postClaude({
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: 'live roundtrip'
|
||||
})
|
||||
expect(promptRes.status).toBe(204)
|
||||
await vi.waitFor(() => expect(events.length).toBeGreaterThan(0), { timeout: 10_000 })
|
||||
expect(events[0].paneKey).toBe(paneKey)
|
||||
expect(events[0].connectionId).toBe('wsl:LiveDistro')
|
||||
const working = events[0].payload as { state: string; prompt: string; agentType: string }
|
||||
expect(working.state).toBe('working')
|
||||
expect(working.prompt).toBe('live roundtrip')
|
||||
expect(working.agentType).toBe('claude')
|
||||
|
||||
const stopRes = await postClaude({ hook_event_name: 'Stop' })
|
||||
expect(stopRes.status).toBe(204)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const done = events.find((e) => (e.payload as { state?: string }).state === 'done')
|
||||
expect(done).toBeTruthy()
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
|
||||
// Link death must be breadcrumbed and scheduled for restart — a silent
|
||||
// mux/child death would blackhole every later envelope while the guest
|
||||
// keeps returning 204 (the exact failure signature from the Windows rig).
|
||||
child?.kill()
|
||||
await vi.waitFor(
|
||||
() => expect(warns.some((w) => w.includes('scheduling restart'))).toBe(true),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
}, 40_000)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,389 @@
|
||||
// WSL hook relay host side: the SFTP-shaped fs adapter over the relay's fs
|
||||
// bridge (including a full run of the unchanged remote hook installers), and
|
||||
// the per-distro relay manager state machine with fault injection.
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RelayDispatcher } from '../../relay/dispatcher'
|
||||
import { registerWslHookFsHandlers } from '../../relay/wsl-hook-fs-bridge'
|
||||
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
|
||||
import { createWslHookSftpAdapter } from './wsl-hook-fs-adapter'
|
||||
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import { WslHookRelayManager } from './wsl-hook-relay-manager'
|
||||
import { FAILURE_COOLDOWN_BASE_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps'
|
||||
import {
|
||||
AGENT_HOOK_NOTIFICATION_METHOD,
|
||||
AGENT_HOOK_REQUEST_REPLAY_METHOD
|
||||
} from '../../shared/agent-hook-relay'
|
||||
|
||||
type GuestHarness = {
|
||||
transport: MultiplexerTransport
|
||||
guestDispatcher: RelayDispatcher
|
||||
mux: SshChannelMultiplexer
|
||||
}
|
||||
|
||||
/** In-memory stdio pair: host mux on one end, guest dispatcher on the other
|
||||
* (same harness shape as the relay agent-hook integration test). */
|
||||
function createGuestHarness(): GuestHarness {
|
||||
let relayFeed: ((data: Buffer) => void) | undefined
|
||||
const clientDataCallbacks: ((data: Buffer) => void)[] = []
|
||||
const closeCallbacks: (() => void)[] = []
|
||||
const transport: MultiplexerTransport = {
|
||||
write: (data) => {
|
||||
setImmediate(() => relayFeed?.(data))
|
||||
},
|
||||
onData: (cb) => {
|
||||
clientDataCallbacks.push(cb)
|
||||
},
|
||||
onClose: (cb) => {
|
||||
closeCallbacks.push(cb)
|
||||
}
|
||||
}
|
||||
const guestDispatcher = new RelayDispatcher((data: Buffer) => {
|
||||
setImmediate(() => {
|
||||
for (const cb of clientDataCallbacks) {
|
||||
cb(data)
|
||||
}
|
||||
})
|
||||
})
|
||||
relayFeed = (data) => guestDispatcher.feed(data)
|
||||
const mux = new SshChannelMultiplexer(transport)
|
||||
return { transport, guestDispatcher, mux }
|
||||
}
|
||||
|
||||
// Why skipIf: the fs bridge runs inside the Linux guest and is POSIX-only by
|
||||
// design (posix.resolve). On a Windows dev host tmpdir() yields C:\ paths the
|
||||
// bridge correctly refuses; Windows coverage comes from the live rig runs.
|
||||
describe.skipIf(process.platform === 'win32')(
|
||||
'createWslHookSftpAdapter over the guest fs bridge',
|
||||
() => {
|
||||
let home: string
|
||||
let harness: GuestHarness
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'wsl-guest-home-'))
|
||||
harness = createGuestHarness()
|
||||
registerWslHookFsHandlers(harness.guestDispatcher, home)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
harness.mux.dispose()
|
||||
harness.guestDispatcher.dispose()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('maps guest ENOENT onto ssh2 status code 2', async () => {
|
||||
const adapter = createWslHookSftpAdapter(harness.mux)
|
||||
const err = await new Promise<Error & { code?: number }>((resolve) => {
|
||||
adapter.readFile(`${home}/missing.json`, 'utf8', ((e: Error) => resolve(e)) as never)
|
||||
})
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.code).toBe(2)
|
||||
})
|
||||
|
||||
it('round-trips write/read/stat/rename and rejects paths outside home', async () => {
|
||||
const adapter = createWslHookSftpAdapter(harness.mux)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
adapter.writeFile(
|
||||
`${home}/a.txt`,
|
||||
'hello',
|
||||
{ encoding: 'utf8', mode: 0o600 } as never,
|
||||
((e: Error | null) => (e ? reject(e) : resolve())) as never
|
||||
)
|
||||
})
|
||||
const content = await new Promise<string>((resolve, reject) => {
|
||||
adapter.readFile(`${home}/a.txt`, 'utf8', ((e: Error | null, value: string) =>
|
||||
e ? reject(e) : resolve(value)) as never)
|
||||
})
|
||||
expect(content).toBe('hello')
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
adapter.ext_openssh_rename(`${home}/a.txt`, `${home}/b.txt`, ((e: Error | null) =>
|
||||
e ? reject(e) : resolve()) as never)
|
||||
})
|
||||
expect(existsSync(`${home}/b.txt`)).toBe(true)
|
||||
|
||||
const outside = await new Promise<Error & { code?: number }>((resolve) => {
|
||||
adapter.readFile('/etc/passwd', 'utf8', ((e: Error) => resolve(e)) as never)
|
||||
})
|
||||
expect(outside).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('runs the unchanged remote managed hook installers against a WSL guest home', async () => {
|
||||
const adapter = createWslHookSftpAdapter(harness.mux)
|
||||
const results = await installRemoteManagedAgentHooks(adapter, home)
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results.every((r) => r.state !== 'error')).toBe(true)
|
||||
|
||||
const claudeSettings = JSON.parse(
|
||||
readFileSync(join(home, '.claude', 'settings.json'), 'utf8')
|
||||
)
|
||||
expect(claudeSettings.hooks).toBeTruthy()
|
||||
const script = readFileSync(join(home, '.orca', 'agent-hooks', 'claude-hook.sh'), 'utf8')
|
||||
expect(script).toContain('/hook/claude')
|
||||
}, 20_000)
|
||||
}
|
||||
)
|
||||
|
||||
describe('WslHookRelayManager', () => {
|
||||
// Why: a fixed POSIX guest home keeps this suite runnable on Windows dev
|
||||
// hosts — installHooks is mocked here, so the fs bridge only ever serves
|
||||
// the wslfs.home request and never touches the real filesystem.
|
||||
const home = '/home/wsl-test-user'
|
||||
let harnesses: GuestHarness[]
|
||||
|
||||
beforeEach(() => {
|
||||
harnesses = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const h of harnesses) {
|
||||
h.mux.dispose()
|
||||
h.guestDispatcher.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
function fakeChild(): ChildProcessWithoutNullStreams & { emitClose: () => void } {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
stdin: { write: () => boolean; end: () => void; on: () => void }
|
||||
kill: () => void
|
||||
emitClose: () => void
|
||||
}
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.stdin = { write: () => true, end: () => {}, on: () => {} }
|
||||
child.kill = () => {}
|
||||
child.emitClose = () => child.emit('close', 0)
|
||||
return child as unknown as ChildProcessWithoutNullStreams & { emitClose: () => void }
|
||||
}
|
||||
|
||||
function guestTransport(): MultiplexerTransport {
|
||||
const harness = createGuestHarness()
|
||||
harnesses.push(harness)
|
||||
registerWslHookFsHandlers(harness.guestDispatcher, home)
|
||||
harness.guestDispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({
|
||||
replayed: 0
|
||||
}))
|
||||
return harness.transport
|
||||
}
|
||||
|
||||
function startupError(code: number | null, stderr = ''): Error {
|
||||
return Object.assign(new Error(`exit ${code}`), {
|
||||
startup: { kind: 'exit' as const, code, stderr }
|
||||
})
|
||||
}
|
||||
|
||||
function createManager(overrides: Partial<WslHookRelayManagerDeps>): {
|
||||
manager: WslHookRelayManager
|
||||
deps: WslHookRelayManagerDeps
|
||||
} {
|
||||
const deps: WslHookRelayManagerDeps = {
|
||||
platform: () => 'win32',
|
||||
remoteHooksEnabled: () => true,
|
||||
hookCoordsEnv: () => ({
|
||||
ORCA_AGENT_HOOK_PORT: '43117',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'tok',
|
||||
ORCA_AGENT_HOOK_ENV: 'production',
|
||||
ORCA_AGENT_HOOK_VERSION: '1'
|
||||
}),
|
||||
instanceKey: () => 'testinstance',
|
||||
resolveBundle: () => ({ jsPath: '/fake/wsl-agent-hook-relay.js', version: '0.1.0+abc' }),
|
||||
readBundle: () => Buffer.from('// bundle'),
|
||||
listDistros: async () => ['Ubuntu'],
|
||||
isDistroRunning: vi.fn(async () => true),
|
||||
spawnRelay: vi.fn(() => fakeChild()),
|
||||
runInstall: vi.fn(async () => ({ code: 0, stderr: '' })),
|
||||
waitForSentinel: vi.fn(async () => guestTransport()),
|
||||
ingest: vi.fn(),
|
||||
installHooks: vi.fn(async () => []),
|
||||
warn: vi.fn(),
|
||||
transientRetryDelayMs: 1,
|
||||
...overrides
|
||||
}
|
||||
return { manager: new WslHookRelayManager(deps), deps }
|
||||
}
|
||||
|
||||
it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => {
|
||||
const { manager, deps } = createManager({})
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
// Codex is the one agent whose home Orca redirects for WSL sessions.
|
||||
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
|
||||
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`
|
||||
})
|
||||
|
||||
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe(
|
||||
`${home}/.orca-wsl/agent-hooks/instance-testinstance/endpoint.env`
|
||||
)
|
||||
|
||||
const guest = harnesses[0].guestDispatcher
|
||||
guest.notify(AGENT_HOOK_NOTIFICATION_METHOD, {
|
||||
paneKey: 'tab:leaf',
|
||||
payload: { state: 'working' }
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(deps.ingest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ paneKey: 'tab:leaf' }),
|
||||
'wsl:Ubuntu'
|
||||
)
|
||||
)
|
||||
|
||||
guest.notify(AGENT_HOOK_NOTIFICATION_METHOD, { payload: { state: 'working' } })
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(deps.ingest).toHaveBeenCalledTimes(1)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('resolves the default distro for null and dedupes it with the explicit name', async () => {
|
||||
const { manager, deps } = createManager({})
|
||||
manager.ensureForDistro(null)
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro(null)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
expect(manager.getGuestEndpointFilePath(null)).toBe(
|
||||
`${home}/.orca-wsl/agent-hooks/instance-testinstance/endpoint.env`
|
||||
)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('reinstalls once on a stale-version exit (42) and then connects', async () => {
|
||||
const waitForSentinel = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(startupError(42))
|
||||
.mockImplementationOnce(async () => guestTransport())
|
||||
const { manager, deps } = createManager({ waitForSentinel })
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
expect(deps.runInstall).toHaveBeenCalledTimes(1)
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(2)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('gives up without installing when the guest has no node (43)', async () => {
|
||||
const waitForSentinel = vi.fn().mockRejectedValue(startupError(43))
|
||||
const { manager, deps } = createManager({ waitForSentinel })
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
await vi.waitFor(() =>
|
||||
expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('no node'))
|
||||
)
|
||||
expect(deps.runInstall).not.toHaveBeenCalled()
|
||||
// Cooldown: an immediate re-ensure must not spawn again.
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('retries a bounded number of times on catastrophic wsl.exe failures', async () => {
|
||||
const waitForSentinel = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(startupError(1, 'Catastrophic failure (E_UNEXPECTED)'))
|
||||
.mockRejectedValueOnce(startupError(1, 'Catastrophic failure (E_UNEXPECTED)'))
|
||||
.mockImplementationOnce(async () => guestTransport())
|
||||
const { manager, deps } = createManager({ waitForSentinel })
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(3)
|
||||
expect(deps.runInstall).not.toHaveBeenCalled()
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('marks the distro failed when the relay exits and re-ensures only after cooldown', async () => {
|
||||
const children: ReturnType<typeof fakeChild>[] = []
|
||||
const spawnRelay = vi.fn(() => {
|
||||
const child = fakeChild()
|
||||
children.push(child)
|
||||
return child
|
||||
})
|
||||
const { manager, deps } = createManager({ spawnRelay })
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
|
||||
children[0].emitClose()
|
||||
await vi.waitFor(() =>
|
||||
expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('exited'))
|
||||
)
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(spawnRelay).toHaveBeenCalledTimes(1)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('is inert off-Windows and when remote hooks are disabled', async () => {
|
||||
const offPlatform = createManager({ platform: () => 'darwin' })
|
||||
offPlatform.manager.ensureForDistro('Ubuntu')
|
||||
const disabled = createManager({ remoteHooksEnabled: () => false })
|
||||
disabled.manager.ensureForDistro('Ubuntu')
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(offPlatform.deps.spawnRelay).not.toHaveBeenCalled()
|
||||
expect(disabled.deps.spawnRelay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires WSL fs-bridge home coordinates before exposing an endpoint path', () => {
|
||||
const { manager } = createManager({})
|
||||
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull()
|
||||
expect(manager.getGuestEndpointFilePath(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a fresh state that replaced a failed one while its restart probe was in flight', async () => {
|
||||
// Drain microtasks under fake timers (queueMicrotask is not a faked timer).
|
||||
const flush = async (): Promise<void> => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
let resolveProbe: ((running: boolean) => void) | undefined
|
||||
const isDistroRunning = vi.fn(() => new Promise<boolean>((resolve) => (resolveProbe = resolve)))
|
||||
const spawnRelay = vi.fn(() => fakeChild())
|
||||
// First launch fails outright; the replacement launch never reaches the
|
||||
// sentinel, so its state stays 'starting' with no live mux to clean up.
|
||||
const waitForSentinel = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('relay died before sentinel'))
|
||||
.mockReturnValueOnce(new Promise<never>(() => {}))
|
||||
const { manager, deps } = createManager({ isDistroRunning, spawnRelay, waitForSentinel })
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
await flush()
|
||||
expect(spawnRelay).toHaveBeenCalledTimes(1)
|
||||
expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('relay died before sentinel'))
|
||||
|
||||
// Fire the restart timer; recovery blocks awaiting the distro-running probe.
|
||||
await vi.advanceTimersByTimeAsync(FAILURE_COOLDOWN_BASE_MS + 300)
|
||||
expect(isDistroRunning).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A new WSL PTY spawn re-ensures past the elapsed cooldown, replacing the
|
||||
// failed state in the map while the old state's probe is still pending.
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
await flush()
|
||||
expect(spawnRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Probe resolves 'not running' after the swap: the drop must be skipped so
|
||||
// the replacement's live relay is not orphaned.
|
||||
resolveProbe?.(false)
|
||||
await flush()
|
||||
expect(deps.warn).not.toHaveBeenCalledWith(expect.stringContaining('distro not running'))
|
||||
|
||||
// Fresh state survived: a further ensure dedupes instead of spawning again.
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
await flush()
|
||||
expect(spawnRelay).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
manager.disposeAll()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,328 @@
|
||||
// Host-side lifecycle manager for the guest-resident WSL agent-hook relay
|
||||
// (STA-1515): one relay per distro per instance, ensured from every WSL PTY
|
||||
// spawn, forwarding envelopes into ingestRemote and installing guest hooks.
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
|
||||
import { installWslGuestHooks } from './wsl-hook-fs-adapter'
|
||||
import { buildWslRelaySpawnEnv, launchWslRelayWithInstall } from './wsl-hook-relay-launch'
|
||||
import {
|
||||
defaultWslHookRelayDeps,
|
||||
FAILURE_COOLDOWN_BASE_MS,
|
||||
FAILURE_COOLDOWN_MAX_MS,
|
||||
NO_NODE_COOLDOWN_MS,
|
||||
REINSTALL_MIN_INTERVAL_MS,
|
||||
REINSTALL_ONE_SHOT_DELAY_MS,
|
||||
RUNNING_TEARDOWN_COOLDOWN_MS,
|
||||
STABLE_UPTIME_MS,
|
||||
type WslHookRelayManagerDeps
|
||||
} from './wsl-hook-relay-deps'
|
||||
import { wireWslRelayLink } from './wsl-hook-relay-link'
|
||||
import { WslRelayRecovery } from './wsl-hook-relay-recovery'
|
||||
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
|
||||
import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay'
|
||||
import {
|
||||
sanitizeWslHookInstanceKey,
|
||||
WSL_HOOK_FS_METHODS,
|
||||
wslHookRelayEndpointFilePath
|
||||
} from '../../shared/wsl-hook-relay-contract'
|
||||
|
||||
type DistroState = {
|
||||
/** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */
|
||||
distro: string
|
||||
phase: 'starting' | 'running' | 'failed'
|
||||
child?: ChildProcessWithoutNullStreams
|
||||
mux?: SshChannelMultiplexer
|
||||
guestHome?: string
|
||||
guestEndpointFilePath?: string
|
||||
failures: number
|
||||
cooldownUntil: number
|
||||
connectedAt?: number
|
||||
restartTimer?: ReturnType<typeof setTimeout>
|
||||
reinstallTimer?: ReturnType<typeof setTimeout>
|
||||
lastInstallAt?: number
|
||||
}
|
||||
|
||||
function distroKey(distro: string): string {
|
||||
return distro.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export class WslHookRelayManager {
|
||||
private deps: WslHookRelayManagerDeps
|
||||
private recovery: WslRelayRecovery
|
||||
private states = new Map<string, DistroState>()
|
||||
private defaultDistro: string | null = null
|
||||
private disposed = false
|
||||
private warnedBundleMissing = false
|
||||
|
||||
constructor(deps: Partial<WslHookRelayManagerDeps> = {}) {
|
||||
this.deps = { ...defaultWslHookRelayDeps, ...deps }
|
||||
this.recovery = new WslRelayRecovery({
|
||||
isDistroRunning: (distro) => this.deps.isDistroRunning(distro),
|
||||
warn: (message) => this.deps.warn(message),
|
||||
isDisposed: () => this.disposed,
|
||||
isCurrent: (state) => this.states.get(distroKey(state.distro)) === state,
|
||||
restart: (distro) => this.ensureForDistro(distro),
|
||||
dropState: (state) => {
|
||||
// Why: identity-guarded — a fresh ensure() may own this key by now;
|
||||
// deleting by key alone would orphan its live relay child.
|
||||
const key = distroKey(state.distro)
|
||||
if (this.states.get(key) === state) {
|
||||
this.states.delete(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
|
||||
ensureForDistro(distro: string | null): void {
|
||||
if (this.disposed || this.deps.platform() !== 'win32' || !this.deps.remoteHooksEnabled()) {
|
||||
return
|
||||
}
|
||||
void this.ensureInternal(distro).catch((err) => {
|
||||
this.deps.warn(
|
||||
`[agent-hooks] WSL hook relay ensure failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Guest endpoint file path once known; null before first connect
|
||||
* (callers keep the /p-translated Windows endpoint path until then). */
|
||||
getGuestEndpointFilePath(distro: string | null): string | null {
|
||||
const name = distro ?? this.defaultDistro
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
return this.states.get(distroKey(name))?.guestEndpointFilePath ?? null
|
||||
}
|
||||
|
||||
disposeAll(): void {
|
||||
this.disposed = true
|
||||
for (const state of this.states.values()) {
|
||||
this.recovery.clearTimers(state)
|
||||
state.mux?.dispose()
|
||||
state.child?.kill()
|
||||
}
|
||||
this.states.clear()
|
||||
}
|
||||
|
||||
private async ensureInternal(requestedDistro: string | null): Promise<void> {
|
||||
const distro = requestedDistro ?? (await this.resolveDefaultDistro())
|
||||
if (!distro || this.disposed) {
|
||||
return
|
||||
}
|
||||
const key = distroKey(distro)
|
||||
const existing = this.states.get(key)
|
||||
if (existing) {
|
||||
if (existing.phase === 'running') {
|
||||
void this.maybeReinstallHooks(existing)
|
||||
return
|
||||
}
|
||||
if (existing.phase !== 'failed' || Date.now() < existing.cooldownUntil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const coords = this.deps.hookCoordsEnv()
|
||||
const port = Number(coords.ORCA_AGENT_HOOK_PORT ?? '')
|
||||
if (!Number.isInteger(port) || port <= 0 || !coords.ORCA_AGENT_HOOK_TOKEN) {
|
||||
return
|
||||
}
|
||||
const bundle = this.deps.resolveBundle()
|
||||
if (!bundle) {
|
||||
if (!this.warnedBundleMissing) {
|
||||
this.warnedBundleMissing = true
|
||||
this.deps.warn('[agent-hooks] WSL hook relay bundle not found; run build:relay')
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: restart-stable instance identity keeps the guest endpoint file at
|
||||
// ONE path across restarts so daemon-surviving agents re-coordinate.
|
||||
const instanceKey =
|
||||
sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) ?? `port${port}`
|
||||
if (existing) {
|
||||
this.recovery.clearTimers(existing)
|
||||
}
|
||||
const state: DistroState = {
|
||||
distro,
|
||||
phase: 'starting',
|
||||
failures: existing?.failures ?? 0,
|
||||
cooldownUntil: 0
|
||||
}
|
||||
this.states.set(key, state)
|
||||
|
||||
const env = buildWslRelaySpawnEnv(coords, bundle.version, instanceKey)
|
||||
|
||||
try {
|
||||
await launchWslRelayWithInstall({
|
||||
distro: state.distro,
|
||||
env,
|
||||
bundleJsPath: bundle.jsPath,
|
||||
version: bundle.version,
|
||||
io: this.deps,
|
||||
isDisposed: () => this.disposed,
|
||||
onChild: (child) => {
|
||||
state.child = child
|
||||
},
|
||||
onNoNode: () =>
|
||||
this.markFailed(
|
||||
state,
|
||||
`no node >= 18 found in distro '${state.distro}'; agent hooks stay degraded there`,
|
||||
{ cooldownBaseMs: NO_NODE_COOLDOWN_MS }
|
||||
),
|
||||
onFailure: (message) =>
|
||||
this.markFailed(state, message, { cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS }),
|
||||
connect: (transport, child) => this.connect(state, transport, child, instanceKey)
|
||||
})
|
||||
} catch (err) {
|
||||
// Why: teardown may have already recorded this failure; don't double-
|
||||
// count. A request-level error can leave a live child — never leak it.
|
||||
state.child?.kill()
|
||||
state.mux?.dispose()
|
||||
if (state.phase !== 'failed') {
|
||||
this.markFailed(state, err instanceof Error ? err.message : String(err), {
|
||||
cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async connect(
|
||||
state: DistroState,
|
||||
transport: MultiplexerTransport,
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
instanceKey: string
|
||||
): Promise<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 this.runInstallers(state, mux, homeResult.home)
|
||||
|
||||
if (state.phase === 'failed' || state.mux !== mux) {
|
||||
// Child died while installing — already recorded; don't revive.
|
||||
return
|
||||
}
|
||||
state.phase = 'running'
|
||||
state.connectedAt = Date.now()
|
||||
// Why: one-shot catch-up so a single-spawn session (no later ensure)
|
||||
// still writes Codex's deferred trust after the launch path seeds config.toml.
|
||||
this.recovery.scheduleOneShotReinstall(state, REINSTALL_ONE_SHOT_DELAY_MS, () => {
|
||||
void this.maybeReinstallHooks(state)
|
||||
})
|
||||
void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch(() => {
|
||||
// Fresh relays have nothing to replay; tolerate.
|
||||
})
|
||||
}
|
||||
|
||||
private async runInstallers(
|
||||
state: DistroState,
|
||||
mux: SshChannelMultiplexer,
|
||||
guestHome: string
|
||||
): Promise<void> {
|
||||
state.lastInstallAt = Date.now()
|
||||
await installWslGuestHooks({
|
||||
mux,
|
||||
guestHome,
|
||||
distro: state.distro,
|
||||
installHooks: this.deps.installHooks,
|
||||
warn: this.deps.warn
|
||||
})
|
||||
}
|
||||
|
||||
private async maybeReinstallHooks(state: DistroState): Promise<void> {
|
||||
const mux = state.mux
|
||||
const guestHome = state.guestHome
|
||||
if (
|
||||
!mux ||
|
||||
!guestHome ||
|
||||
mux.isDisposed() ||
|
||||
Date.now() - (state.lastInstallAt ?? 0) < REINSTALL_MIN_INTERVAL_MS
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.runInstallers(state, mux, guestHome)
|
||||
} catch (err) {
|
||||
this.deps.warn(
|
||||
`[agent-hooks] WSL hook reinstall for '${state.distro}' failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Records + breadcrumbs the failure and always arms the restart timer —
|
||||
* one failed relaunch must not end self-recovery; the timer's
|
||||
* distro-running probe keeps this from booting stopped distros. */
|
||||
private markFailed(
|
||||
state: DistroState,
|
||||
message: string,
|
||||
options: { cooldownBaseMs: number }
|
||||
): void {
|
||||
state.phase = 'failed'
|
||||
state.failures++
|
||||
state.child = undefined
|
||||
state.mux = undefined
|
||||
if (state.reinstallTimer) {
|
||||
clearTimeout(state.reinstallTimer)
|
||||
state.reinstallTimer = undefined
|
||||
}
|
||||
state.cooldownUntil =
|
||||
Date.now() + Math.min(options.cooldownBaseMs * state.failures, FAILURE_COOLDOWN_MAX_MS)
|
||||
this.deps.warn(`[agent-hooks] WSL hook relay (${state.distro}): ${message}`)
|
||||
this.recovery.scheduleRestart(state)
|
||||
}
|
||||
|
||||
private async resolveDefaultDistro(): Promise<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()
|
||||
@@ -0,0 +1,155 @@
|
||||
// Restart-timer policy: recovery must re-ensure a running distro, must NOT
|
||||
// boot a stopped one (wsl -d starts stopped distros), and must respect state
|
||||
// currency and disposal.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { WslRelayRecovery, type WslRelayRecoveryState } from './wsl-hook-relay-recovery'
|
||||
|
||||
function makeState(): WslRelayRecoveryState {
|
||||
return { distro: 'Ubuntu', cooldownUntil: Date.now() - 1_000 }
|
||||
}
|
||||
|
||||
function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise<void> {
|
||||
return vi.waitFor(() => expect(condition()).toBe(true), { timeout: timeoutMs })
|
||||
}
|
||||
|
||||
describe('WslRelayRecovery', () => {
|
||||
it('re-ensures the distro when the restart timer fires and the distro is running', async () => {
|
||||
const restart = vi.fn()
|
||||
const recovery = new WslRelayRecovery({
|
||||
isDistroRunning: async () => true,
|
||||
warn: vi.fn(),
|
||||
isDisposed: () => false,
|
||||
isCurrent: () => true,
|
||||
restart,
|
||||
dropState: vi.fn()
|
||||
})
|
||||
const state = makeState()
|
||||
recovery.scheduleRestart(state)
|
||||
await waitFor(() => restart.mock.calls.length === 1)
|
||||
expect(restart).toHaveBeenCalledWith('Ubuntu')
|
||||
})
|
||||
|
||||
it('drops the state instead of booting a stopped distro', async () => {
|
||||
const restart = vi.fn()
|
||||
const dropState = vi.fn()
|
||||
const warn = vi.fn()
|
||||
const recovery = new WslRelayRecovery({
|
||||
isDistroRunning: async () => false,
|
||||
warn,
|
||||
isDisposed: () => false,
|
||||
isCurrent: () => true,
|
||||
restart,
|
||||
dropState
|
||||
})
|
||||
const state = makeState()
|
||||
recovery.scheduleRestart(state)
|
||||
await waitFor(() => dropState.mock.calls.length === 1)
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('distro not running'))
|
||||
})
|
||||
|
||||
it('does nothing when the state was replaced or the manager is disposed', async () => {
|
||||
const restart = vi.fn()
|
||||
const probe = vi.fn(async () => true)
|
||||
const recovery = new WslRelayRecovery({
|
||||
isDistroRunning: probe,
|
||||
warn: vi.fn(),
|
||||
isDisposed: () => false,
|
||||
isCurrent: () => false,
|
||||
restart,
|
||||
dropState: vi.fn()
|
||||
})
|
||||
const state = makeState()
|
||||
recovery.scheduleRestart(state)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not drop or restart when the state was replaced mid-probe (probe false)', async () => {
|
||||
let current = true
|
||||
let resolveProbe: ((running: boolean) => void) | undefined
|
||||
const probe = vi.fn(() => new Promise<boolean>((resolve) => (resolveProbe = resolve)))
|
||||
const restart = vi.fn()
|
||||
const dropState = vi.fn()
|
||||
const recovery = new WslRelayRecovery({
|
||||
isDistroRunning: probe,
|
||||
warn: vi.fn(),
|
||||
isDisposed: () => false,
|
||||
isCurrent: () => current,
|
||||
restart,
|
||||
dropState
|
||||
})
|
||||
const state = makeState()
|
||||
recovery.scheduleRestart(state)
|
||||
await waitFor(() => probe.mock.calls.length === 1)
|
||||
// A fresh ensure() replaces this state while the probe is in flight.
|
||||
current = false
|
||||
resolveProbe?.(false)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(dropState).not.toHaveBeenCalled()
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not restart when the state was replaced mid-probe (probe true)', async () => {
|
||||
let current = true
|
||||
let resolveProbe: ((running: boolean) => void) | undefined
|
||||
const probe = vi.fn(() => new Promise<boolean>((resolve) => (resolveProbe = resolve)))
|
||||
const restart = vi.fn()
|
||||
const recovery = new WslRelayRecovery({
|
||||
isDistroRunning: probe,
|
||||
warn: vi.fn(),
|
||||
isDisposed: () => false,
|
||||
isCurrent: () => current,
|
||||
restart,
|
||||
dropState: vi.fn()
|
||||
})
|
||||
const state = makeState()
|
||||
recovery.scheduleRestart(state)
|
||||
await waitFor(() => probe.mock.calls.length === 1)
|
||||
current = false
|
||||
resolveProbe?.(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scheduleOneShotReinstall is a no-op once disposed', async () => {
|
||||
const run = vi.fn()
|
||||
const recovery = new WslRelayRecovery({
|
||||
isDistroRunning: async () => true,
|
||||
warn: vi.fn(),
|
||||
isDisposed: () => true,
|
||||
isCurrent: () => true,
|
||||
restart: vi.fn(),
|
||||
dropState: vi.fn()
|
||||
})
|
||||
const state = makeState()
|
||||
recovery.scheduleOneShotReinstall(state, 10, run)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
expect(state.reinstallTimer).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clearTimers cancels both pending timers', async () => {
|
||||
const restart = vi.fn()
|
||||
const recovery = new WslRelayRecovery({
|
||||
isDistroRunning: async () => true,
|
||||
warn: vi.fn(),
|
||||
isDisposed: () => false,
|
||||
isCurrent: () => true,
|
||||
restart,
|
||||
dropState: vi.fn()
|
||||
})
|
||||
const state = makeState()
|
||||
const reinstall = vi.fn()
|
||||
recovery.scheduleRestart(state)
|
||||
recovery.scheduleOneShotReinstall(state, 100, reinstall)
|
||||
recovery.clearTimers(state)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
expect(reinstall).not.toHaveBeenCalled()
|
||||
expect(state.restartTimer).toBeUndefined()
|
||||
expect(state.reinstallTimer).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// Restart/reinstall timer policy for WSL hook relay states. Owns the two
|
||||
// self-recovery timers so the manager's state machine stays declarative:
|
||||
// WHEN to retry lives here, WHAT retrying means stays in the manager.
|
||||
export type WslRelayRecoveryState = {
|
||||
distro: string
|
||||
cooldownUntil: number
|
||||
restartTimer?: ReturnType<typeof setTimeout>
|
||||
reinstallTimer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
export type WslRelayRecoveryIo = {
|
||||
isDistroRunning: (distro: string) => Promise<boolean>
|
||||
warn: (message: string) => void
|
||||
isDisposed: () => boolean
|
||||
/** True while this state object is still the one in the manager's map. */
|
||||
isCurrent: (state: WslRelayRecoveryState) => boolean
|
||||
restart: (distro: string) => void
|
||||
dropState: (state: WslRelayRecoveryState) => void
|
||||
}
|
||||
|
||||
export class WslRelayRecovery {
|
||||
constructor(private io: WslRelayRecoveryIo) {}
|
||||
|
||||
/** Arms the restart timer for the state's cooldown. The probe gate matters:
|
||||
* `wsl -d` BOOTS a stopped distro, so recovery must never resurrect a VM
|
||||
* the user shut down — a stopped distro has no live agents anyway. */
|
||||
scheduleRestart(state: WslRelayRecoveryState): void {
|
||||
if (this.io.isDisposed() || state.restartTimer) {
|
||||
return
|
||||
}
|
||||
const delayMs = Math.max(state.cooldownUntil - Date.now(), 0) + 250
|
||||
state.restartTimer = setTimeout(() => {
|
||||
state.restartTimer = undefined
|
||||
void this.restartIfDistroRunning(state)
|
||||
}, delayMs)
|
||||
state.restartTimer.unref?.()
|
||||
}
|
||||
|
||||
scheduleOneShotReinstall(state: WslRelayRecoveryState, delayMs: number, run: () => void): void {
|
||||
if (this.io.isDisposed()) {
|
||||
return
|
||||
}
|
||||
state.reinstallTimer = setTimeout(() => {
|
||||
state.reinstallTimer = undefined
|
||||
run()
|
||||
}, delayMs)
|
||||
state.reinstallTimer.unref?.()
|
||||
}
|
||||
|
||||
clearTimers(state: WslRelayRecoveryState): void {
|
||||
if (state.restartTimer) {
|
||||
clearTimeout(state.restartTimer)
|
||||
state.restartTimer = undefined
|
||||
}
|
||||
if (state.reinstallTimer) {
|
||||
clearTimeout(state.reinstallTimer)
|
||||
state.reinstallTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async restartIfDistroRunning(state: WslRelayRecoveryState): Promise<void> {
|
||||
if (this.io.isDisposed() || !this.io.isCurrent(state)) {
|
||||
return
|
||||
}
|
||||
const running = await this.io.isDistroRunning(state.distro)
|
||||
// Why: a fresh ensure() may have replaced this state during the probe
|
||||
// await — dropping/restarting here would then act on the replacement,
|
||||
// orphaning its live relay child outside the manager's map.
|
||||
if (this.io.isDisposed() || !this.io.isCurrent(state)) {
|
||||
return
|
||||
}
|
||||
if (!running) {
|
||||
this.io.warn(
|
||||
`[agent-hooks] WSL hook relay (${state.distro}): distro not running (or probe failed); restart skipped (next WSL terminal re-ensures)`
|
||||
)
|
||||
this.io.dropState(state)
|
||||
return
|
||||
}
|
||||
this.io.restart(state.distro)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Sentinel wait for the WSL relay: consuming stdout until the READY sentinel,
|
||||
// handing trailing/subsequent bytes to the mux in wire order via the microtask
|
||||
// flush, and failing (kill + reject) on overflow, close, timeout, and NUL noise.
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from '../ssh/relay-protocol'
|
||||
import {
|
||||
MAX_STARTUP_BUFFER_BYTES,
|
||||
waitForWslRelaySentinel,
|
||||
type WslRelayStartupFailure
|
||||
} from './wsl-hook-relay-sentinel'
|
||||
|
||||
type FakeChild = ChildProcessWithoutNullStreams & { kill: ReturnType<typeof vi.fn> }
|
||||
|
||||
function fakeChild(): FakeChild {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
stdin: { write: ReturnType<typeof vi.fn> }
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
}
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.stdin = { write: vi.fn(() => true) }
|
||||
child.kill = vi.fn()
|
||||
return child as unknown as FakeChild
|
||||
}
|
||||
|
||||
function emitStdout(child: ChildProcessWithoutNullStreams, data: string | Buffer): void {
|
||||
child.stdout.emit('data', Buffer.isBuffer(data) ? data : Buffer.from(data))
|
||||
}
|
||||
|
||||
function emitStderr(child: ChildProcessWithoutNullStreams, data: Buffer): void {
|
||||
child.stderr.emit('data', data)
|
||||
}
|
||||
|
||||
function catchStartup(
|
||||
promise: Promise<unknown>
|
||||
): Promise<Error & { startup?: WslRelayStartupFailure }> {
|
||||
return promise.then(
|
||||
() => {
|
||||
throw new Error('expected the sentinel wait to reject')
|
||||
},
|
||||
(err: Error & { startup?: WslRelayStartupFailure }) => err
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('waitForWslRelaySentinel', () => {
|
||||
it('resolves when the sentinel is split across two chunks', async () => {
|
||||
const child = fakeChild()
|
||||
const promise = waitForWslRelaySentinel(child)
|
||||
const sentinel = Buffer.from(RELAY_SENTINEL)
|
||||
const mid = Math.floor(sentinel.length / 2)
|
||||
emitStdout(child, sentinel.subarray(0, mid))
|
||||
emitStdout(child, sentinel.subarray(mid))
|
||||
const transport = await promise
|
||||
expect(typeof transport.write).toBe('function')
|
||||
expect(typeof transport.onData).toBe('function')
|
||||
})
|
||||
|
||||
it('resolves past leading garbage and hands trailing bytes to onData', async () => {
|
||||
const child = fakeChild()
|
||||
const promise = waitForWslRelaySentinel(child)
|
||||
emitStdout(
|
||||
child,
|
||||
Buffer.concat([Buffer.from('junk noise '), Buffer.from(RELAY_SENTINEL), Buffer.from('TRAIL')])
|
||||
)
|
||||
const transport = await promise
|
||||
const received: string[] = []
|
||||
transport.onData((d) => received.push(d.toString('utf8')))
|
||||
await Promise.resolve()
|
||||
expect(received).toEqual(['TRAIL'])
|
||||
})
|
||||
|
||||
it('delivers a pending trailing chunk before a later direct chunk, in wire order', async () => {
|
||||
const child = fakeChild()
|
||||
const promise = waitForWslRelaySentinel(child)
|
||||
emitStdout(child, Buffer.concat([Buffer.from(RELAY_SENTINEL), Buffer.from('FIRST')]))
|
||||
const transport = await promise
|
||||
const received: string[] = []
|
||||
transport.onData((d) => received.push(d.toString('utf8')))
|
||||
// A real stdout 'data' event is a macrotask; the queued pending flush is a
|
||||
// microtask and must land ahead of any subsequent direct chunk.
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(() => {
|
||||
emitStdout(child, 'SECOND')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
expect(received).toEqual(['FIRST', 'SECOND'])
|
||||
})
|
||||
|
||||
it('defers the pending flush to a microtask so a caller can finish wiring', async () => {
|
||||
const child = fakeChild()
|
||||
const promise = waitForWslRelaySentinel(child)
|
||||
emitStdout(child, Buffer.concat([Buffer.from(RELAY_SENTINEL), Buffer.from('DEFER')]))
|
||||
const transport = await promise
|
||||
const received: string[] = []
|
||||
transport.onData((d) => received.push(d.toString('utf8')))
|
||||
// Nothing dispatched synchronously at registration — a second handler could
|
||||
// still be added this tick before the first envelope flushes.
|
||||
expect(received).toEqual([])
|
||||
await Promise.resolve()
|
||||
expect(received).toEqual(['DEFER'])
|
||||
})
|
||||
|
||||
it('kills the child and rejects when startup output exceeds 64 KiB before the sentinel', async () => {
|
||||
const child = fakeChild()
|
||||
const settled = catchStartup(waitForWslRelaySentinel(child))
|
||||
emitStdout(child, Buffer.alloc(MAX_STARTUP_BUFFER_BYTES + 1, 0x41))
|
||||
const err = await settled
|
||||
expect(err.message).toMatch(/64 KiB/)
|
||||
expect(child.kill).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects with an exit failure when the child closes before the sentinel', async () => {
|
||||
const child = fakeChild()
|
||||
const settled = catchStartup(waitForWslRelaySentinel(child))
|
||||
child.emit('close', 7)
|
||||
const err = await settled
|
||||
expect(err.startup).toEqual({ kind: 'exit', code: 7, stderr: '' })
|
||||
})
|
||||
|
||||
it('kills the child and rejects with a timeout after the sentinel deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const child = fakeChild()
|
||||
const settled = catchStartup(waitForWslRelaySentinel(child))
|
||||
await vi.advanceTimersByTimeAsync(RELAY_SENTINEL_TIMEOUT_MS + 1)
|
||||
const err = await settled
|
||||
expect(child.kill).toHaveBeenCalled()
|
||||
expect(err.startup?.kind).toBe('timeout')
|
||||
})
|
||||
|
||||
it('strips NUL bytes from the stderr failure detail', async () => {
|
||||
const child = fakeChild()
|
||||
const settled = catchStartup(waitForWslRelaySentinel(child))
|
||||
// wsl.exe without WSL_UTF8 emits UTF-16LE — "E_FAIL" as NUL-interleaved ASCII.
|
||||
const nulLaden = Buffer.from('E_FAIL'.split('').flatMap((c) => [c.charCodeAt(0), 0]))
|
||||
emitStderr(child, nulLaden)
|
||||
child.emit('close', 1)
|
||||
const err = await settled
|
||||
expect(err.startup?.stderr).toBe('E_FAIL')
|
||||
expect(err.message).toContain('E_FAIL')
|
||||
expect(err.message).not.toContain(String.fromCharCode(0))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
// Sentinel wait for the WSL agent-hook relay: consume the guest child's
|
||||
// stdout until the READY sentinel, then hand the remaining stdio over as a
|
||||
// MultiplexerTransport. WSL twin of the SSH deploy's waitForSentinel, over a
|
||||
// ChildProcess instead of a ClientChannel.
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
|
||||
import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from '../ssh/relay-protocol'
|
||||
import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
|
||||
|
||||
export const MAX_STARTUP_BUFFER_BYTES = 64 * 1024
|
||||
|
||||
// Why: without WSL_UTF8, wsl.exe's own messages arrive UTF-16LE; NUL bytes
|
||||
// in breadcrumbs and the catastrophic-failure matcher must not depend on the
|
||||
// env var having taken effect (older wsl.exe ignores it).
|
||||
export function decodeWslText(value: string): string {
|
||||
return value.split(String.fromCharCode(0)).join('')
|
||||
}
|
||||
|
||||
export type WslRelayStartupFailure = {
|
||||
kind: 'exit' | 'timeout'
|
||||
code: number | null
|
||||
stderr: string
|
||||
}
|
||||
|
||||
/** Wait for the relay's ready sentinel on the child's stdout, then hand the
|
||||
* remaining stdio over as a MultiplexerTransport. WSL twin of the SSH
|
||||
* deploy's waitForSentinel, over a ChildProcess instead of a ClientChannel. */
|
||||
export function waitForWslRelaySentinel(
|
||||
child: ChildProcessWithoutNullStreams
|
||||
): Promise<MultiplexerTransport> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
let sentinelSeen = false
|
||||
let stdoutBuffer: Buffer = Buffer.alloc(0)
|
||||
let stderrOutput = ''
|
||||
let exitCode: number | null = null
|
||||
const sentinel = Buffer.from(RELAY_SENTINEL, 'utf8')
|
||||
const dataCallbacks: ((data: Buffer) => void)[] = []
|
||||
const closeCallbacks: (() => void)[] = []
|
||||
// Why: post-sentinel chunks queue until the mux registers onData, then
|
||||
// flush as a microtask — after the caller's synchronous wiring (the mux
|
||||
// constructor registers onData before the manager can add notification
|
||||
// handlers, so a synchronous flush could dispatch an early envelope to
|
||||
// zero handlers) yet before any subsequent stdout IO event, so the frame
|
||||
// decoder never sees chunks out of order. A setImmediate handoff would
|
||||
// NOT preserve that: it is a macrotask the next 'data' event can beat.
|
||||
const pendingChunks: Buffer[] = []
|
||||
let closedNotified = false
|
||||
|
||||
const fail = (failure: WslRelayStartupFailure): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
reject(Object.assign(new Error(formatStartupFailure(failure)), { startup: failure }))
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill()
|
||||
fail({ kind: 'timeout', code: null, stderr: stderrOutput })
|
||||
}, RELAY_SENTINEL_TIMEOUT_MS)
|
||||
|
||||
const notifyClosed = (): void => {
|
||||
if (!closedNotified) {
|
||||
closedNotified = true
|
||||
for (const cb of closeCallbacks) {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = (chunk: Buffer): void => {
|
||||
if (dataCallbacks.length === 0) {
|
||||
pendingChunks.push(chunk)
|
||||
return
|
||||
}
|
||||
for (const cb of dataCallbacks) {
|
||||
cb(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
child.stderr.on('data', (d: Buffer) => {
|
||||
stderrOutput = (stderrOutput + decodeWslText(d.toString('utf8'))).slice(
|
||||
-MAX_STARTUP_BUFFER_BYTES
|
||||
)
|
||||
})
|
||||
child.on('error', (err) =>
|
||||
fail({ kind: 'exit', code: null, stderr: `${stderrOutput}\n${err.message}` })
|
||||
)
|
||||
child.on('exit', (code) => {
|
||||
exitCode = code
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
if (sentinelSeen) {
|
||||
notifyClosed()
|
||||
return
|
||||
}
|
||||
fail({ kind: 'exit', code: code ?? exitCode, stderr: stderrOutput })
|
||||
})
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
if (sentinelSeen) {
|
||||
dispatch(chunk)
|
||||
return
|
||||
}
|
||||
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
|
||||
const idx = stdoutBuffer.indexOf(sentinel)
|
||||
if (idx === -1) {
|
||||
// Why: pre-sentinel stdout is untrusted startup noise; cap it so a
|
||||
// broken guest cannot grow memory until the timeout fires.
|
||||
if (stdoutBuffer.length > MAX_STARTUP_BUFFER_BYTES) {
|
||||
child.kill()
|
||||
fail({ kind: 'exit', code: null, stderr: 'startup output exceeded 64 KiB' })
|
||||
}
|
||||
return
|
||||
}
|
||||
sentinelSeen = true
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
const trailing = stdoutBuffer.subarray(idx + sentinel.length)
|
||||
if (trailing.length > 0) {
|
||||
pendingChunks.push(trailing)
|
||||
}
|
||||
const transport: MultiplexerTransport = {
|
||||
write: (data) => {
|
||||
try {
|
||||
child.stdin.write(data)
|
||||
} catch {
|
||||
// Channel already closing — mux close handling takes over.
|
||||
}
|
||||
},
|
||||
onData: (cb) => {
|
||||
dataCallbacks.push(cb)
|
||||
if (dataCallbacks.length === 1 && pendingChunks.length > 0) {
|
||||
queueMicrotask(() => {
|
||||
for (const pending of pendingChunks.splice(0)) {
|
||||
for (const dataCb of dataCallbacks) {
|
||||
dataCb(pending)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
onClose: (cb) => closeCallbacks.push(cb),
|
||||
close: () => child.kill()
|
||||
}
|
||||
resolve(transport)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function formatStartupFailure(failure: WslRelayStartupFailure): string {
|
||||
const detail = failure.stderr.trim()
|
||||
if (failure.kind === 'timeout') {
|
||||
return `WSL hook relay did not become ready within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s${detail ? `: ${detail}` : ''}`
|
||||
}
|
||||
return `WSL hook relay exited (code ${failure.code ?? 'unknown'})${detail ? `: ${detail}` : ''}`
|
||||
}
|
||||
@@ -44,7 +44,9 @@ export function remoteSessionSources(
|
||||
),
|
||||
// The remote host owns the transcript disk, so the local readdir in the
|
||||
// Claude parser is skipped; the walked listing supplies the sibling
|
||||
// subagent counts instead.
|
||||
// subagent counts instead. Partitioning also prunes the subagent
|
||||
// transcripts themselves, which would otherwise list as phantom
|
||||
// top-level sessions carrying the parent's sessionId.
|
||||
collectSubagentSiblingCounts: true
|
||||
},
|
||||
source(
|
||||
|
||||
@@ -201,6 +201,50 @@ describe('scanRemoteAiVaultSessions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes Claude subagent transcripts from remote scans', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/claude-session.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-07-04T04:00:00.000Z',
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Spawn a Task' }] }
|
||||
}
|
||||
]),
|
||||
40
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/claude-session/subagents/agent-abc123.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
sessionId: 'claude-session',
|
||||
isSidechain: true,
|
||||
agentId: 'abc123',
|
||||
timestamp: '2026-07-04T04:01:00.000Z',
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Subagent task prompt' }] }
|
||||
}
|
||||
]),
|
||||
41
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
// Only the parent session surfaces; the subagent transcript would carry
|
||||
// the parent's sessionId and list as a phantom top-level row.
|
||||
expect(result.sessions.map((session) => session.filePath)).toEqual([
|
||||
'/home/ada/.claude/projects/repo/claude-session.jsonl'
|
||||
])
|
||||
})
|
||||
|
||||
it('counts remote sibling subagent transcripts for zero-turn Claude sessions', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
@@ -239,7 +283,7 @@ describe('scanRemoteAiVaultSessions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores remote subagent siblings for Claude sessions with real turns', async () => {
|
||||
it('counts remote subagent siblings for Claude sessions with real turns', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/live-session.jsonl',
|
||||
@@ -267,10 +311,12 @@ describe('scanRemoteAiVaultSessions', () => {
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
// The walk listing supplies the count for every session (the row badge),
|
||||
// not only zero-turn recoverable ones.
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
sessionId: 'live-session',
|
||||
messageCount: 1,
|
||||
subagentTranscriptCount: 0
|
||||
subagentTranscriptCount: 1
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -209,10 +209,11 @@ async function parseRemoteSessionCandidate(
|
||||
return null
|
||||
}
|
||||
const session = await candidate.source.parse(candidate.file, read.content, context)
|
||||
// Mirror the local zero-turn-only rule: sibling subagent transcripts are
|
||||
// recoverable signal only when the parent conversation persisted no turns.
|
||||
// Mirror the local rule: every session carries its sibling subagent
|
||||
// transcript count (row badge; recoverable signal at zero turns). The
|
||||
// walk listing supplies it — the parser can't readdir a remote disk.
|
||||
const subagentTranscriptCount = candidate.subagentTranscriptCount ?? 0
|
||||
if (session && session.messageCount === 0 && subagentTranscriptCount > 0) {
|
||||
if (session && subagentTranscriptCount > 0) {
|
||||
return { ...session, subagentTranscriptCount }
|
||||
}
|
||||
return session
|
||||
|
||||
@@ -141,6 +141,7 @@ function session(
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: `codex resume ${sessionId}`
|
||||
resumeCommand: `codex resume ${sessionId}`,
|
||||
subagent: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,17 @@ const aiVaultListResultSchema = z.object({
|
||||
// fields) parseable; they simply report no recoverable-empty sessions.
|
||||
queuedMessageCount: z.number().default(0),
|
||||
subagentTranscriptCount: z.number().default(0),
|
||||
resumeCommand: z.string()
|
||||
resumeCommand: z.string(),
|
||||
// The default keeps remote hosts running an older build (no subagent
|
||||
// field) parseable; scanned top-level sessions carry null anyway.
|
||||
subagent: z
|
||||
.object({
|
||||
parentSessionId: z.string(),
|
||||
agentType: z.string().nullable(),
|
||||
status: z.enum(['running', 'completed', 'failed', 'stopped']).nullable()
|
||||
})
|
||||
.nullable()
|
||||
.default(null)
|
||||
})
|
||||
),
|
||||
issues: z.array(
|
||||
|
||||
@@ -121,7 +121,8 @@ export function finalizeSession(
|
||||
cwd: accumulator.cwd,
|
||||
platform,
|
||||
codexHome: options.codexHome
|
||||
})
|
||||
}),
|
||||
subagent: null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
describe('scanAiVaultSessions Claude subagent pruning', () => {
|
||||
it('prunes subagents/ transcripts instead of listing them as sessions', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-prune-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const subagentsDir = join(roots.claudeProjectsDir, 'project', 'claude-session', 'subagents')
|
||||
await mkdir(subagentsDir, { recursive: true })
|
||||
|
||||
await writeFile(
|
||||
join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-05-01T10:00:00.000Z',
|
||||
cwd: '/repo/app',
|
||||
message: { role: 'user', content: 'Spawn a Task' }
|
||||
}
|
||||
])
|
||||
)
|
||||
await writeFile(
|
||||
join(subagentsDir, 'agent-abc123.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'claude-session',
|
||||
isSidechain: true,
|
||||
agentId: 'abc123',
|
||||
timestamp: '2026-05-01T10:01:00.000Z',
|
||||
cwd: '/repo/app',
|
||||
message: { role: 'user', content: 'Subagent task prompt' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
// Only the parent session surfaces; the subagent transcript is not a row,
|
||||
// but it is still counted for the row's "N subagents" affordance.
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
sessionId: 'claude-session',
|
||||
subagentTranscriptCount: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,396 @@
|
||||
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { listClaudeSubagentSessions } from './session-scanner-claude-subagents'
|
||||
import { countSubagentTranscripts } from './session-scanner-subagent-transcripts'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
async function writeJsonlFile(filePath: string, records: unknown[]): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
async function writeSubagentTranscript(args: {
|
||||
subagentsDir: string
|
||||
agentId: string
|
||||
taskPrompt: string
|
||||
timestamp: string
|
||||
}): Promise<void> {
|
||||
await writeJsonlFile(join(args.subagentsDir, `agent-${args.agentId}.jsonl`), [
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'parent-session',
|
||||
isSidechain: true,
|
||||
agentId: args.agentId,
|
||||
timestamp: args.timestamp,
|
||||
cwd: '/tmp/claude',
|
||||
message: { role: 'user', content: args.taskPrompt }
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
describe('listClaudeSubagentSessions', () => {
|
||||
it('lists subagents titled by the spawn description, newest first, linked to the parent', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-list-'))
|
||||
tempRoots.push(root)
|
||||
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
|
||||
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
|
||||
|
||||
await writeJsonlFile(parentFilePath, [
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'parent-session',
|
||||
timestamp: '2026-07-05T10:00:00.000Z',
|
||||
cwd: '/tmp/claude',
|
||||
message: { role: 'user', content: 'Parent prompt' }
|
||||
}
|
||||
])
|
||||
await writeSubagentTranscript({
|
||||
subagentsDir,
|
||||
agentId: 'older',
|
||||
taskPrompt: 'Task prompt for the older subagent',
|
||||
timestamp: '2026-07-05T10:01:00.000Z'
|
||||
})
|
||||
await writeFile(
|
||||
join(subagentsDir, 'agent-older.meta.json'),
|
||||
JSON.stringify({
|
||||
agentType: 'Explore',
|
||||
description: 'Map the scanner internals',
|
||||
toolUseId: 'toolu_older'
|
||||
})
|
||||
)
|
||||
await writeSubagentTranscript({
|
||||
subagentsDir,
|
||||
agentId: 'newer',
|
||||
taskPrompt: 'Task prompt for the newer subagent',
|
||||
timestamp: '2026-07-05T10:02:00.000Z'
|
||||
})
|
||||
await writeFile(
|
||||
join(subagentsDir, 'agent-newer.meta.json'),
|
||||
JSON.stringify({
|
||||
agentType: 'general-purpose',
|
||||
description: 'Check latest news',
|
||||
toolUseId: 'toolu_newer'
|
||||
})
|
||||
)
|
||||
|
||||
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.title)).toEqual([
|
||||
'Check latest news',
|
||||
'Map the scanner internals'
|
||||
])
|
||||
// No task notifications in the parent and freshly written transcripts:
|
||||
// both count as still running.
|
||||
expect(result.sessions.map((session) => session.subagent)).toEqual([
|
||||
{ parentSessionId: 'parent-session', agentType: 'general-purpose', status: 'running' },
|
||||
{ parentSessionId: 'parent-session', agentType: 'Explore', status: 'running' }
|
||||
])
|
||||
expect(result.sessions.map((session) => session.messageCount)).toEqual([1, 1])
|
||||
})
|
||||
|
||||
it('falls back to the Task prompt title when the meta sidecar is missing or corrupt', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-meta-'))
|
||||
tempRoots.push(root)
|
||||
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
|
||||
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
|
||||
|
||||
await writeSubagentTranscript({
|
||||
subagentsDir,
|
||||
agentId: 'nometa',
|
||||
taskPrompt: 'Sidecarless task prompt',
|
||||
timestamp: '2026-07-05T10:01:00.000Z'
|
||||
})
|
||||
await writeSubagentTranscript({
|
||||
subagentsDir,
|
||||
agentId: 'badmeta',
|
||||
taskPrompt: 'Corrupt sidecar task prompt',
|
||||
timestamp: '2026-07-05T10:02:00.000Z'
|
||||
})
|
||||
await writeFile(join(subagentsDir, 'agent-badmeta.meta.json'), '{not json')
|
||||
|
||||
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.title)).toEqual([
|
||||
'Corrupt sidecar task prompt',
|
||||
'Sidecarless task prompt'
|
||||
])
|
||||
expect(result.sessions.map((session) => session.subagent?.agentType)).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('resolves statuses from parent task notifications, falling back to transcript recency', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-status-'))
|
||||
tempRoots.push(root)
|
||||
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
|
||||
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
|
||||
|
||||
await writeJsonlFile(parentFilePath, [
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'parent-session',
|
||||
timestamp: '2026-07-05T10:00:00.000Z',
|
||||
cwd: '/tmp/claude',
|
||||
message: { role: 'user', content: 'Parent prompt' }
|
||||
},
|
||||
{
|
||||
// Interim notification: superseded by the terminal one below.
|
||||
type: 'user',
|
||||
sessionId: 'parent-session',
|
||||
timestamp: '2026-07-05T10:00:30.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content:
|
||||
'<task-notification>\n<task-id>finished</task-id>\n<status>running</status>\n</task-notification>'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'parent-session',
|
||||
timestamp: '2026-07-05T10:01:00.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content:
|
||||
'<task-notification>\n<task-id>finished</task-id>\n<status>completed</status>\n</task-notification>'
|
||||
}
|
||||
},
|
||||
{
|
||||
// queue-operation records carry the notification as a plain string.
|
||||
type: 'queue-operation',
|
||||
operation: 'enqueue',
|
||||
sessionId: 'parent-session',
|
||||
timestamp: '2026-07-05T10:02:00.000Z',
|
||||
content:
|
||||
'<task-notification>\n<task-id>crashed</task-id>\n<status>failed</status>\n</task-notification>'
|
||||
},
|
||||
{
|
||||
// Synchronous Tasks finish with a toolUseResult record, no notification.
|
||||
type: 'user',
|
||||
sessionId: 'parent-session',
|
||||
timestamp: '2026-07-05T10:03:00.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'toolu_sync', content: 'done' }]
|
||||
},
|
||||
toolUseResult: { status: 'completed', agentId: 'syncdone', agentType: 'Explore' }
|
||||
}
|
||||
])
|
||||
for (const agentId of ['finished', 'crashed', 'active', 'stale', 'syncdone']) {
|
||||
await writeSubagentTranscript({
|
||||
subagentsDir,
|
||||
agentId,
|
||||
taskPrompt: `Task for ${agentId}`,
|
||||
timestamp: '2026-07-05T10:00:10.000Z'
|
||||
})
|
||||
}
|
||||
// A transcript silent for an hour with no terminal notification has no
|
||||
// trustworthy status.
|
||||
const staleTime = new Date(Date.now() - 60 * 60_000)
|
||||
await utimes(join(subagentsDir, 'agent-stale.jsonl'), staleTime, staleTime)
|
||||
|
||||
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
const statusByTitle = Object.fromEntries(
|
||||
result.sessions.map((session) => [session.title, session.subagent?.status ?? null])
|
||||
)
|
||||
expect(statusByTitle).toEqual({
|
||||
'Task for finished': 'completed',
|
||||
'Task for crashed': 'failed',
|
||||
'Task for active': 'running',
|
||||
'Task for stale': null,
|
||||
'Task for syncdone': 'completed'
|
||||
})
|
||||
expect(await countSubagentTranscripts(parentFilePath)).toBe(5)
|
||||
})
|
||||
|
||||
it('reads a terminal status whose <status> sits past the title truncation limit', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-longnote-'))
|
||||
tempRoots.push(root)
|
||||
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
|
||||
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
|
||||
|
||||
// A real notification carries <tool-use-id>/<output-file> before <status>,
|
||||
// pushing <status> well past the 96-char title cap; delivered as a user
|
||||
// message it must still be read untruncated so the terminal status wins
|
||||
// over the transcript-recency fallback.
|
||||
const notification =
|
||||
'<task-notification>\n<task-id>bigtask</task-id>\n' +
|
||||
'<tool-use-id>toolu_01HB57dwUE6hQVK6Mh8jVFHA</tool-use-id>\n' +
|
||||
'<output-file>/private/tmp/claude-501/-Users-me-orca-workspaces-project/' +
|
||||
'parent-session/tasks/bigtask.output</output-file>\n' +
|
||||
'<status>completed</status>\n<summary>Agent finished</summary>\n</task-notification>'
|
||||
expect(notification.indexOf('<status>')).toBeGreaterThan(96)
|
||||
|
||||
await writeJsonlFile(parentFilePath, [
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'parent-session',
|
||||
timestamp: '2026-07-05T10:00:00.000Z',
|
||||
cwd: '/tmp/claude',
|
||||
message: { role: 'user', content: 'Parent prompt' }
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'parent-session',
|
||||
timestamp: '2026-07-05T10:01:00.000Z',
|
||||
message: { role: 'user', content: notification }
|
||||
}
|
||||
])
|
||||
// Freshly written, so a dropped status would fall back to 'running' — the
|
||||
// assertion below only holds when the notification is parsed untruncated.
|
||||
await writeSubagentTranscript({
|
||||
subagentsDir,
|
||||
agentId: 'bigtask',
|
||||
taskPrompt: 'Task for bigtask',
|
||||
timestamp: '2026-07-05T10:00:10.000Z'
|
||||
})
|
||||
|
||||
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.subagent?.status)).toEqual(['completed'])
|
||||
})
|
||||
|
||||
it('ignores a quoted notification in a user prompt so it cannot overwrite a real status', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-quoted-'))
|
||||
tempRoots.push(root)
|
||||
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
|
||||
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
|
||||
|
||||
await writeJsonlFile(parentFilePath, [
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'parent-session',
|
||||
timestamp: '2026-07-05T10:00:00.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content:
|
||||
'<task-notification>\n<task-id>quoted</task-id>\n<status>completed</status>\n</task-notification>'
|
||||
}
|
||||
},
|
||||
{
|
||||
// A user prompt that merely quotes an earlier notification must not
|
||||
// supersede the real terminal status recorded above.
|
||||
type: 'user',
|
||||
sessionId: 'parent-session',
|
||||
timestamp: '2026-07-05T10:01:00.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content:
|
||||
'Re-run: <task-notification>\n<task-id>quoted</task-id>\n<status>failed</status>\n</task-notification>'
|
||||
}
|
||||
}
|
||||
])
|
||||
const staleTime = new Date(Date.now() - 60 * 60_000)
|
||||
await writeSubagentTranscript({
|
||||
subagentsDir,
|
||||
agentId: 'quoted',
|
||||
taskPrompt: 'Task for quoted',
|
||||
timestamp: '2026-07-05T10:00:10.000Z'
|
||||
})
|
||||
await utimes(join(subagentsDir, 'agent-quoted.jsonl'), staleTime, staleTime)
|
||||
|
||||
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.subagent?.status)).toEqual(['completed'])
|
||||
})
|
||||
|
||||
it('reads a sync-Task toolUseResult status even when its report quotes a notification', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-turmarker-'))
|
||||
tempRoots.push(root)
|
||||
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
|
||||
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
|
||||
|
||||
await writeJsonlFile(parentFilePath, [
|
||||
{
|
||||
// A sync-Task completion: the terminal status lives in toolUseResult,
|
||||
// but the subagent's own report text quotes <task-notification>, so the
|
||||
// raw-line prefilter must not misroute it into the notification branch.
|
||||
type: 'user',
|
||||
sessionId: 'parent-session',
|
||||
timestamp: '2026-07-05T10:03:00.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_report',
|
||||
content: 'The subagent explained how <task-notification> records work.'
|
||||
}
|
||||
]
|
||||
},
|
||||
toolUseResult: { status: 'completed', agentId: 'reporter', agentType: 'Explore' }
|
||||
}
|
||||
])
|
||||
// Stale, so a dropped status would fall back to null, not 'completed'.
|
||||
const staleTime = new Date(Date.now() - 60 * 60_000)
|
||||
await writeSubagentTranscript({
|
||||
subagentsDir,
|
||||
agentId: 'reporter',
|
||||
taskPrompt: 'Task for reporter',
|
||||
timestamp: '2026-07-05T10:00:10.000Z'
|
||||
})
|
||||
await utimes(join(subagentsDir, 'agent-reporter.jsonl'), staleTime, staleTime)
|
||||
|
||||
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.subagent?.status)).toEqual(['completed'])
|
||||
})
|
||||
|
||||
it('links to the parent derived from its file path even when the transcript has no sessionId', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-parent-'))
|
||||
tempRoots.push(root)
|
||||
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
|
||||
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
|
||||
|
||||
// A transcript with no sessionId record: the parser falls back to the
|
||||
// subagent's own filename-derived id, so parentSessionId must not use it.
|
||||
await writeJsonlFile(join(subagentsDir, 'agent-nosession.jsonl'), [
|
||||
{
|
||||
type: 'user',
|
||||
isSidechain: true,
|
||||
agentId: 'nosession',
|
||||
timestamp: '2026-07-05T10:01:00.000Z',
|
||||
cwd: '/tmp/claude',
|
||||
message: { role: 'user', content: 'Orphan subagent prompt' }
|
||||
}
|
||||
])
|
||||
|
||||
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.subagent?.parentSessionId)).toEqual([
|
||||
'parent-session'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty list when the session never spawned subagents', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-none-'))
|
||||
tempRoots.push(root)
|
||||
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
|
||||
await writeJsonlFile(parentFilePath, [
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'parent-session',
|
||||
timestamp: '2026-07-05T10:00:00.000Z',
|
||||
cwd: '/tmp/claude',
|
||||
message: { role: 'user', content: 'Parent prompt' }
|
||||
}
|
||||
])
|
||||
|
||||
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
|
||||
|
||||
expect(result).toEqual({ sessions: [], issues: [] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,281 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type {
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession,
|
||||
AiVaultSubagentListResult,
|
||||
AiVaultSubagentRunStatus
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator'
|
||||
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
|
||||
import {
|
||||
isSubagentTranscriptFileName,
|
||||
subagentTranscriptsDirFor,
|
||||
SUBAGENT_TRANSCRIPT_PREFIX
|
||||
} from './session-scanner-subagent-transcripts'
|
||||
import {
|
||||
asRecord,
|
||||
errorMessage,
|
||||
extractString,
|
||||
normalizeTitleText,
|
||||
parseJsonObject
|
||||
} from './session-scanner-values'
|
||||
|
||||
// A subagent writing nothing for this long without a terminal notification is
|
||||
// treated as no longer running (its status stays unknown rather than stale).
|
||||
const SUBAGENT_RUNNING_RECENCY_MS = 5 * 60_000
|
||||
// Match the main scanner's deliberate parse batching (SESSION_PARSE_CONCURRENCY):
|
||||
// opening every subagent read stream at once stalls over SSH/WSL UNC paths.
|
||||
const SUBAGENT_PARSE_CONCURRENCY = 8
|
||||
|
||||
const TASK_NOTIFICATION_MARKER = '<task-notification>'
|
||||
const TOOL_USE_RESULT_MARKER = '"toolUseResult"'
|
||||
// A sync-Task toolUseResult sets a status only when it carries an agentId. Tool
|
||||
// output records (Read/Bash) also carry "toolUseResult" and are the largest lines
|
||||
// in a transcript, so gating on this second marker keeps the status pass from
|
||||
// JSON-parsing ~all of the file's bytes on every on-demand fetch.
|
||||
const TOOL_USE_RESULT_AGENT_ID_MARKER = '"agentId"'
|
||||
const TASK_ID_PATTERN = /<task-id>([^<]+)<\/task-id>/
|
||||
const TASK_STATUS_PATTERN = /<status>([a-z_]+)<\/status>/
|
||||
|
||||
// Statuses reported by parent-transcript <task-notification> records
|
||||
// (background Tasks) and toolUseResult records (synchronous Tasks).
|
||||
const TERMINAL_TASK_STATUSES: Record<string, AiVaultSubagentRunStatus> = {
|
||||
completed: 'completed',
|
||||
failed: 'failed',
|
||||
killed: 'stopped',
|
||||
stopped: 'stopped'
|
||||
}
|
||||
|
||||
type ClaudeSubagentMeta = {
|
||||
description: string | null
|
||||
agentType: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* List the Task subagent transcripts of one Claude session, on demand. The
|
||||
* main scan prunes `subagents/` subtrees for speed, so this is the only path
|
||||
* that reads them — and only when the user expands a session's details.
|
||||
*/
|
||||
export async function listClaudeSubagentSessions(args: {
|
||||
parentFilePath: string
|
||||
platform?: NodeJS.Platform
|
||||
now?: number
|
||||
}): Promise<AiVaultSubagentListResult> {
|
||||
const platform = args.platform ?? process.platform
|
||||
const now = args.now ?? Date.now()
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const subagentsDir = subagentTranscriptsDirFor(args.parentFilePath)
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(subagentsDir, { withFileTypes: true })
|
||||
} catch {
|
||||
return { sessions: [], issues }
|
||||
}
|
||||
|
||||
const transcriptNames = entries
|
||||
.filter((entry) => isSubagentTranscriptFileName(entry.name, entry.isFile()))
|
||||
.map((entry) => entry.name)
|
||||
if (transcriptNames.length === 0) {
|
||||
return { sessions: [], issues }
|
||||
}
|
||||
|
||||
// One pass over the parent transcript resolves every subagent's status.
|
||||
const statusByAgentId = await collectSubagentTaskStatuses(args.parentFilePath)
|
||||
// Why: the layout fixes the parent's sessionId (<parent>.jsonl -> subagents/);
|
||||
// deriving it here avoids a subagent transcript with no sessionId records
|
||||
// linking to its own filename-derived id instead of the parent.
|
||||
const parentSessionId = sessionIdFromFileName(args.parentFilePath)
|
||||
const parsed: (AiVaultSession | null)[] = []
|
||||
for (let index = 0; index < transcriptNames.length; index += SUBAGENT_PARSE_CONCURRENCY) {
|
||||
const batch = transcriptNames.slice(index, index + SUBAGENT_PARSE_CONCURRENCY)
|
||||
const batchResults = await Promise.all(
|
||||
batch.map((name) =>
|
||||
parseSubagentTranscript({
|
||||
filePath: join(subagentsDir, name),
|
||||
agentId: subagentIdFromFileName(name),
|
||||
parentSessionId,
|
||||
statusByAgentId,
|
||||
now,
|
||||
platform,
|
||||
issues
|
||||
})
|
||||
)
|
||||
)
|
||||
parsed.push(...batchResults)
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: parsed
|
||||
.filter((session): session is AiVaultSession => session !== null)
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left)),
|
||||
issues
|
||||
}
|
||||
}
|
||||
|
||||
function subagentIdFromFileName(name: string): string {
|
||||
return basename(name, extname(name)).slice(SUBAGENT_TRANSCRIPT_PREFIX.length)
|
||||
}
|
||||
|
||||
async function parseSubagentTranscript(args: {
|
||||
filePath: string
|
||||
agentId: string
|
||||
parentSessionId: string
|
||||
statusByAgentId: ReadonlyMap<string, string>
|
||||
now: number
|
||||
platform: NodeJS.Platform
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<AiVaultSession | null> {
|
||||
try {
|
||||
const fileStat = await stat(args.filePath)
|
||||
const session = await parseClaudeSessionFile(
|
||||
{
|
||||
path: args.filePath,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
modifiedAt: fileStat.mtime.toISOString()
|
||||
},
|
||||
args.platform
|
||||
)
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
const meta = await readSubagentMeta(args.filePath)
|
||||
return {
|
||||
...session,
|
||||
// Why: the spawn description is the name the main agent gave this Task;
|
||||
// it beats the transcript-derived fallback (the raw Task prompt).
|
||||
title: meta.description ?? session.title,
|
||||
subagent: {
|
||||
// The parent's sessionId is derived from its file path, not the
|
||||
// subagent transcript, so it survives transcripts with no sessionId.
|
||||
parentSessionId: args.parentSessionId,
|
||||
agentType: meta.agentType,
|
||||
status: resolveSubagentStatus({
|
||||
reportedStatus: args.statusByAgentId.get(args.agentId),
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
now: args.now
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
args.issues.push({ agent: 'claude', path: args.filePath, message: errorMessage(err) })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSubagentStatus(args: {
|
||||
reportedStatus: string | undefined
|
||||
mtimeMs: number
|
||||
now: number
|
||||
}): AiVaultSubagentRunStatus | null {
|
||||
const terminal = args.reportedStatus ? TERMINAL_TASK_STATUSES[args.reportedStatus] : undefined
|
||||
if (terminal) {
|
||||
return terminal
|
||||
}
|
||||
// No terminal notification yet: a recently-written transcript is running;
|
||||
// a stale one has no trustworthy status (e.g. the parent was interrupted).
|
||||
return args.now - args.mtimeMs <= SUBAGENT_RUNNING_RECENCY_MS ? 'running' : null
|
||||
}
|
||||
|
||||
// The parent transcript reports subagent completion in two shapes: sync Tasks
|
||||
// finish with a toolUseResult record ({ agentId, status }); background Tasks
|
||||
// launch with status 'async_launched' and finish with a <task-notification>
|
||||
// whose <task-id> is the agentId. Last record wins, so interim statuses (e.g.
|
||||
// 'async_launched', notification 'running') are superseded by terminal ones.
|
||||
async function collectSubagentTaskStatuses(parentFilePath: string): Promise<Map<string, string>> {
|
||||
const statuses = new Map<string, string>()
|
||||
try {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(parentFilePath, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
for await (const line of lines) {
|
||||
const hasNotification = line.includes(TASK_NOTIFICATION_MARKER)
|
||||
const hasTaskResult =
|
||||
line.includes(TOOL_USE_RESULT_MARKER) && line.includes(TOOL_USE_RESULT_AGENT_ID_MARKER)
|
||||
if (!hasNotification && !hasTaskResult) {
|
||||
continue
|
||||
}
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
// Only records whose text IS the notification set a status; a user prompt
|
||||
// (or a sync-Task toolUseResult report) that merely quotes one also trips
|
||||
// the raw-line prefilter, so it must not consume the record — fall through
|
||||
// to the toolUseResult branch below rather than dropping a real status.
|
||||
// Accepted limitation: a user message that BEGINS with a verbatim
|
||||
// notification is indistinguishable from a real delivery (records carry no
|
||||
// provenance marker), so it can set a bogus status. The impact is a wrong
|
||||
// dot on a view-only row, so we don't gate on user-type markers here (that
|
||||
// would risk dropping genuine harness-delivered statuses).
|
||||
if (hasNotification) {
|
||||
const text = taskNotificationText(record)
|
||||
if (text.startsWith(TASK_NOTIFICATION_MARKER)) {
|
||||
const taskId = TASK_ID_PATTERN.exec(text)?.[1]?.trim()
|
||||
const status = TASK_STATUS_PATTERN.exec(text)?.[1]
|
||||
if (taskId && status) {
|
||||
statuses.set(taskId, status)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
const result = asRecord(record.toolUseResult)
|
||||
const agentId = extractString(result?.agentId)
|
||||
const status = extractString(result?.status)
|
||||
if (agentId && status) {
|
||||
statuses.set(agentId, status)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A missing/unreadable parent transcript degrades to recency-only status.
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
// The <status> marker follows <tool-use-id>/<output-file> lines in real
|
||||
// notifications, so it sits well past the 96-char title cap — the notification
|
||||
// text must be read untruncated (unlike titles/previews). Both delivery shapes
|
||||
// appear: queue-operation records carry it as top-level `content`; user-message
|
||||
// records under `message.content` (a string, or text content blocks).
|
||||
function taskNotificationText(record: Record<string, unknown>): string {
|
||||
const direct = extractString(record.content)
|
||||
if (direct) {
|
||||
return direct
|
||||
}
|
||||
const content = asRecord(record.message)?.content
|
||||
if (typeof content === 'string') {
|
||||
return content.trim()
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content.map(taskNotificationBlockText).filter(Boolean).join(' ').trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function taskNotificationBlockText(block: unknown): string {
|
||||
if (typeof block === 'string') {
|
||||
return block
|
||||
}
|
||||
const record = asRecord(block)
|
||||
return extractString(record?.text) ?? extractString(record?.content) ?? ''
|
||||
}
|
||||
|
||||
// Claude writes an `agent-<id>.meta.json` sidecar for every subagent transcript,
|
||||
// carrying the Task tool's spawn `description` and its resolved `agentType`.
|
||||
async function readSubagentMeta(transcriptPath: string): Promise<ClaudeSubagentMeta> {
|
||||
const metaPath = `${transcriptPath.slice(0, -extname(transcriptPath).length)}.meta.json`
|
||||
try {
|
||||
const record = asRecord(JSON.parse(await readFile(metaPath, 'utf-8')) as unknown)
|
||||
return {
|
||||
description: normalizeTitleText(extractString(record?.description) ?? ''),
|
||||
agentType: extractString(record?.agentType)
|
||||
}
|
||||
} catch {
|
||||
// The sidecar is optional; the transcript still yields a usable title.
|
||||
return { description: null, agentType: null }
|
||||
}
|
||||
}
|
||||
@@ -85,4 +85,55 @@ describe('parseCodexSessionFile', () => {
|
||||
|
||||
expect(session?.totalTokens).toBe(150)
|
||||
})
|
||||
|
||||
it('extracts the model from turn context, latest turn winning', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-model-'))
|
||||
tempRoots.push(root)
|
||||
const sessionPath = join(root, 'sessions', '2026', '07', '05', 'rollout-model.jsonl')
|
||||
await mkdir(dirname(sessionPath), { recursive: true })
|
||||
|
||||
await writeFile(
|
||||
sessionPath,
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-05T10:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'model-session', cwd: '/repo/app' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-05T10:00:01.000Z',
|
||||
type: 'turn_context',
|
||||
payload: { cwd: '/repo/app', model: 'gpt-5.1' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-05T10:00:02.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Switch the model' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
// A /model switch mid-session writes a later turn_context.
|
||||
timestamp: '2026-07-05T10:00:03.000Z',
|
||||
type: 'turn_context',
|
||||
payload: { cwd: '/repo/app', model: 'gpt-5.5' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const sessionStat = await stat(sessionPath)
|
||||
const session = await parseCodexSessionFile(
|
||||
{
|
||||
path: sessionPath,
|
||||
mtimeMs: sessionStat.mtimeMs,
|
||||
modifiedAt: sessionStat.mtime.toISOString()
|
||||
},
|
||||
'darwin',
|
||||
root
|
||||
)
|
||||
|
||||
expect(session?.model).toBe('gpt-5.5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,6 +70,8 @@ export async function walkSessionFiles(
|
||||
options: {
|
||||
extensions: Set<string>
|
||||
filePredicate?: (path: string) => boolean
|
||||
// Return false to skip descending into a directory (matched by its name),
|
||||
// so pruned subtrees are never stat'd or parsed.
|
||||
directoryPredicate?: (name: string) => boolean
|
||||
}
|
||||
): Promise<string[]> {
|
||||
|
||||
@@ -153,13 +153,16 @@ export async function finalizeClaudeSessionParseState(
|
||||
// session name (ai-title) should outrank the raw first prompt when present.
|
||||
snapshot.accumulator.fallbackTitle =
|
||||
snapshot.generatedTitle ?? snapshot.firstUserTitle ?? snapshot.metaTitle
|
||||
// Only a zero-turn transcript needs its sibling subagent transcripts counted;
|
||||
// for normal sessions the extra directory read is skipped. The sibling dir
|
||||
// Every session's sibling subagent transcripts are counted (one readdir):
|
||||
// the row UI shows the count without expanding details, and for zero-turn
|
||||
// transcripts it doubles as the recoverable-content signal. The sibling dir
|
||||
// lives on the host that owns the transcript, so content fetched from a
|
||||
// remote (SSH) host must not readdir this machine's disk.
|
||||
// remote (SSH) host must not readdir this machine's disk. Runtime hosts scan
|
||||
// their own local disk (their host id is stamped after parse), so they are
|
||||
// already covered by the undefined-executionHostId branch.
|
||||
const ownsTranscriptDisk =
|
||||
!options.executionHostId || options.executionHostId === LOCAL_EXECUTION_HOST_ID
|
||||
if (snapshot.accumulator.messageCount === 0 && ownsTranscriptDisk) {
|
||||
if (ownsTranscriptDisk) {
|
||||
snapshot.accumulator.subagentTranscriptCount = await countSubagentTranscripts(
|
||||
snapshot.accumulator.filePath
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -99,7 +99,7 @@ describe('recoverable-but-empty Claude sessions', () => {
|
||||
expect(isAiVaultSessionRecoverableEmpty(session!)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not count subagent transcripts for a session that has real turns', async () => {
|
||||
it('counts subagent transcripts for a session that has real turns without flagging it recoverable', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-nonempty-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
@@ -127,8 +127,10 @@ describe('recoverable-but-empty Claude sessions', () => {
|
||||
const session = result.sessions.find((entry) => entry.sessionId === sessionId)
|
||||
|
||||
expect(session?.messageCount).toBe(1)
|
||||
// Skipped for sessions with content: the directory read only runs at zero turns.
|
||||
expect(session?.subagentTranscriptCount).toBe(0)
|
||||
// Counted for every local session (the row's "N subagents" affordance),
|
||||
// but a session with real turns is never surfaced as recoverable-empty.
|
||||
expect(session?.subagentTranscriptCount).toBe(1)
|
||||
expect(isAiVaultSessionRecoverableEmpty(session!)).toBe(false)
|
||||
})
|
||||
|
||||
it('counts queued messages net of remove and dequeue operations', async () => {
|
||||
@@ -242,4 +244,18 @@ describe('countSubagentTranscripts', () => {
|
||||
|
||||
expect(await countSubagentTranscripts(transcriptPath)).toBe(2)
|
||||
})
|
||||
|
||||
it('excludes non-agent .jsonl files and directories named like transcripts', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-sub-predicate-'))
|
||||
tempRoots.push(root)
|
||||
const transcriptPath = join(root, 'session.jsonl')
|
||||
const subagentsDir = join(root, 'session', 'subagents')
|
||||
await writeJsonlFile(join(subagentsDir, 'agent-a.jsonl'), [{ type: 'user' }])
|
||||
// A stray sibling artifact and a directory would inflate the row badge past
|
||||
// what the on-demand lister shows; both must fail the shared predicate.
|
||||
await writeFile(join(subagentsDir, 'notes.jsonl'), '{}')
|
||||
await mkdir(join(subagentsDir, 'agent-x.jsonl'))
|
||||
|
||||
expect(await countSubagentTranscripts(transcriptPath)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { homedir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
|
||||
import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts'
|
||||
import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery'
|
||||
import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources'
|
||||
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
|
||||
@@ -39,6 +40,18 @@ const DEVIN_TRANSCRIPTS_DIR = join(
|
||||
'transcripts'
|
||||
)
|
||||
|
||||
// The local host and each WSL distro's `~/.claude/projects`. Callers reading
|
||||
// Claude session files by path use these roots to reject arbitrary paths.
|
||||
export function claudeProjectsRootDirs(args: {
|
||||
claudeProjectsDir?: string
|
||||
wslHomeDirs?: readonly string[]
|
||||
}): string[] {
|
||||
return [
|
||||
args.claudeProjectsDir ?? CLAUDE_PROJECTS_DIR,
|
||||
...(args.wslHomeDirs ?? []).map((homeDir) => join(homeDir, '.claude', 'projects'))
|
||||
]
|
||||
}
|
||||
|
||||
export async function discoverAiVaultSessionSources(args: {
|
||||
options: AiVaultScanOptions
|
||||
limitPerAgent: number
|
||||
@@ -77,10 +90,10 @@ function claudeDiscoveries(
|
||||
limit: number,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<SessionFileDiscovery>[] {
|
||||
return [
|
||||
options.claudeProjectsDir ?? CLAUDE_PROJECTS_DIR,
|
||||
...wslHomeDirs.map((homeDir) => join(homeDir, '.claude', 'projects'))
|
||||
].map((rootDir) =>
|
||||
return claudeProjectsRootDirs({
|
||||
claudeProjectsDir: options.claudeProjectsDir,
|
||||
wslHomeDirs
|
||||
}).map((rootDir) =>
|
||||
discoverFiles({
|
||||
rootDir,
|
||||
limit,
|
||||
@@ -89,8 +102,9 @@ function claudeDiscoveries(
|
||||
extensions: ['.jsonl'],
|
||||
// Why: Task subagent transcripts under `<session>/subagents/` share the parent
|
||||
// sessionId and aren't independently resumable, so they'd just duplicate the
|
||||
// parent as untitled rows; prune the subtree instead of indexing it.
|
||||
directoryPredicate: (name) => name !== 'subagents'
|
||||
// parent as untitled rows; prune the subtree and read them on demand under
|
||||
// their parent instead.
|
||||
directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, join } from 'node:path'
|
||||
|
||||
// Exported so discovery can prune these subtrees using the same literal that
|
||||
// locates them here — the pruning comment and behavior can't drift.
|
||||
export const SUBAGENT_DIR_NAME = 'subagents'
|
||||
|
||||
// Claude names every Task subagent transcript `agent-<id>.jsonl`. The row-badge
|
||||
// count, the recoverable-empty signal, and the on-demand lister all key off this
|
||||
// one predicate so the "N subagents" badge can never disagree with the expanded
|
||||
// list (a stray non-transcript `.jsonl` or a dir named `x.jsonl` would otherwise
|
||||
// inflate the count).
|
||||
export const SUBAGENT_TRANSCRIPT_PREFIX = 'agent-'
|
||||
|
||||
export function isSubagentTranscriptFileName(name: string, isFile: boolean): boolean {
|
||||
return (
|
||||
isFile &&
|
||||
name.startsWith(SUBAGENT_TRANSCRIPT_PREFIX) &&
|
||||
extname(name).toLowerCase() === '.jsonl'
|
||||
)
|
||||
}
|
||||
|
||||
// Claude writes subagent transcripts to a sibling directory named after the
|
||||
// parent transcript file (…/<enc>/<uuid>.jsonl → …/<enc>/<uuid>/subagents/).
|
||||
// These survive intact even when the parent conversation persisted zero turns,
|
||||
// so they are the recoverable signal that keeps such a session from being hidden.
|
||||
export function subagentTranscriptsDirFor(transcriptFilePath: string): string {
|
||||
const stem = basename(transcriptFilePath, extname(transcriptFilePath))
|
||||
return join(dirname(transcriptFilePath), stem, 'subagents')
|
||||
return join(dirname(transcriptFilePath), stem, SUBAGENT_DIR_NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,19 +36,20 @@ export function subagentTranscriptsDirFor(transcriptFilePath: string): string {
|
||||
* transcripts and are excluded.
|
||||
*/
|
||||
export async function countSubagentTranscripts(transcriptFilePath: string): Promise<number> {
|
||||
let entries: string[]
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath))
|
||||
entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath), { withFileTypes: true })
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
return entries.filter((name) => name.endsWith('.jsonl')).length
|
||||
return entries.filter((entry) => isSubagentTranscriptFileName(entry.name, entry.isFile())).length
|
||||
}
|
||||
|
||||
// Direct child of a subagents dir: `<parent>/<uuid>/subagents/<file>.jsonl`.
|
||||
// Greedy prefix means nested subagent trees attribute to their nearest parent,
|
||||
// matching the local direct-children-only readdir semantics.
|
||||
const SUBAGENT_DIRECT_CHILD_PATTERN = /^(.*)[\\/]subagents[\\/][^\\/]+\.jsonl$/i
|
||||
// Direct child of a subagents dir: `<parent>/<uuid>/subagents/agent-<id>.jsonl`.
|
||||
// The `agent-` prefix mirrors the local count/list predicate so remote badges
|
||||
// can't over-count. Greedy prefix means nested subagent trees attribute to their
|
||||
// nearest parent, matching the local direct-children-only readdir semantics.
|
||||
const SUBAGENT_DIRECT_CHILD_PATTERN = /^(.*)[\\/]subagents[\\/]agent-[^\\/]+\.jsonl$/i
|
||||
const SUBAGENT_SUBTREE_PATTERN = /[\\/]subagents[\\/]/i
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { app } from 'electron'
|
||||
import type { CodexManagedAccount } from '../../shared/types'
|
||||
import type { Store } from '../persistence'
|
||||
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
|
||||
import { writeFileAtomically } from './fs-utils'
|
||||
import {
|
||||
getOrcaManagedCodexHomePath,
|
||||
@@ -657,9 +658,7 @@ export class CodexRuntimeHomeService {
|
||||
|
||||
private getWslRuntimeHomePath(distro: string): string | null {
|
||||
const home = getWslHome(distro)
|
||||
return home
|
||||
? this.joinWslPath(home, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
|
||||
: null
|
||||
return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null
|
||||
}
|
||||
|
||||
private safeReadBackActiveWslAccountBeforeRestart(
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const execFileMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: execFileMock
|
||||
}))
|
||||
|
||||
import { _internals } from './codex-wsl-hook-install-plan'
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
_internals.resetWslCanonicalPathCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform)
|
||||
})
|
||||
|
||||
describe('canonicalizeWslLinuxPath', () => {
|
||||
it('returns the path unchanged off Windows without spawning wsl.exe', () => {
|
||||
setPlatform('linux')
|
||||
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alice')).toBe('/home/alice')
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never blocks: returns null and schedules an async resolution on first call', () => {
|
||||
setPlatform('win32')
|
||||
const result = _internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
const [file, args] = execFileMock.mock.calls[0]
|
||||
expect(file).toBe('wsl.exe')
|
||||
expect(args).toEqual(['-d', 'Ubuntu', '--', 'readlink', '-f', '--', '/home/alias'])
|
||||
})
|
||||
|
||||
it('caches the resolved canonical path and stops spawning wsl.exe', () => {
|
||||
setPlatform('win32')
|
||||
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull()
|
||||
|
||||
const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void
|
||||
callback(null, '/home/alice\n')
|
||||
|
||||
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBe('/home/alice')
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not spawn a second subprocess while one is in flight', () => {
|
||||
setPlatform('win32')
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries after a failed resolution rather than caching the failure', () => {
|
||||
setPlatform('win32')
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void
|
||||
callback(new Error('wsl unreachable'), '')
|
||||
|
||||
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores non-absolute readlink output', () => {
|
||||
setPlatform('win32')
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void
|
||||
callback(null, 'readlink: missing operand\n')
|
||||
|
||||
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { win32 as pathWin32 } from 'node:path'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
|
||||
@@ -29,24 +29,56 @@ function toDefaultWslLinuxPath(windowsPath: string): string {
|
||||
return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2].replace(/\\/g, '/')}`
|
||||
}
|
||||
|
||||
const WSL_CANONICALIZE_TIMEOUT_MS = 5000
|
||||
|
||||
// Why: `readlink -f` over wsl.exe stalls up to the timeout on a cold or wedged
|
||||
// distro. Running it synchronously on the Electron main process froze the UI on
|
||||
// every Codex WSL launch, so resolve it off-thread and cache the stable result.
|
||||
const canonicalWslPathCache = new Map<string, string>()
|
||||
const inFlightWslCanonicalizations = new Set<string>()
|
||||
|
||||
function wslCanonicalizeCacheKey(distro: string, linuxPath: string): string {
|
||||
return `${distro}\x00${linuxPath}`
|
||||
}
|
||||
|
||||
function scheduleWslLinuxPathCanonicalization(distro: string, linuxPath: string): void {
|
||||
const key = wslCanonicalizeCacheKey(distro, linuxPath)
|
||||
// Why: cap the wedged-WSL blast radius to a single background subprocess per
|
||||
// (distro, path); the cache holds the answer once one resolution succeeds.
|
||||
if (canonicalWslPathCache.has(key) || inFlightWslCanonicalizations.has(key)) {
|
||||
return
|
||||
}
|
||||
inFlightWslCanonicalizations.add(key)
|
||||
execFile(
|
||||
'wsl.exe',
|
||||
['-d', distro, '--', 'readlink', '-f', '--', linuxPath],
|
||||
{ encoding: 'utf-8', timeout: WSL_CANONICALIZE_TIMEOUT_MS, windowsHide: true },
|
||||
(error, stdout) => {
|
||||
inFlightWslCanonicalizations.delete(key)
|
||||
if (error) {
|
||||
return
|
||||
}
|
||||
const canonicalPath = stdout.trim()
|
||||
if (canonicalPath.startsWith('/')) {
|
||||
canonicalWslPathCache.set(key, canonicalPath)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function canonicalizeWslLinuxPath(distro: string, linuxPath: string): string | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return linuxPath
|
||||
}
|
||||
try {
|
||||
const canonicalPath = execFileSync(
|
||||
'wsl.exe',
|
||||
['-d', distro, '--', 'readlink', '-f', '--', linuxPath],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 5000
|
||||
}
|
||||
).trim()
|
||||
return canonicalPath.startsWith('/') ? canonicalPath : null
|
||||
} catch {
|
||||
return null
|
||||
const cached = canonicalWslPathCache.get(wslCanonicalizeCacheKey(distro, linuxPath))
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
// Why: no canonical path resolved yet — start the off-thread resolution and
|
||||
// let the caller use the logical path for now (identical unless HOME crosses a
|
||||
// symlink). The next launch reads the cached canonical path.
|
||||
scheduleWslLinuxPathCanonicalization(distro, linuxPath)
|
||||
return null
|
||||
}
|
||||
|
||||
export function createCodexWslRuntimeHookInstallPlan(
|
||||
@@ -85,3 +117,11 @@ export function createCodexWslRuntimeHookInstallPlan(
|
||||
trustConfigPath: `${linuxRuntimeHome}/hooks.json`
|
||||
}
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
canonicalizeWslLinuxPath,
|
||||
resetWslCanonicalPathCache(): void {
|
||||
canonicalWslPathCache.clear()
|
||||
inFlightWslCanonicalizations.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1184,9 +1184,25 @@ export class CodexHookService {
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.codex/hooks.json`
|
||||
const remoteTomlPath = `${remoteHome.replace(/\/$/, '')}/.codex/config.toml`
|
||||
async installRemote(
|
||||
sftp: SFTPWrapper,
|
||||
remoteHome: string,
|
||||
options?: {
|
||||
/** Explicit CODEX_HOME dir (flat layout: hooks.json/config.toml at its
|
||||
* root). WSL sessions read Orca's managed runtime home, not ~/.codex —
|
||||
* installing to the default location leaves those sessions hookless. */
|
||||
codexHomeDir?: string
|
||||
/** Skip the trust write when config.toml doesn't exist yet. The WSL
|
||||
* runtime home's config.toml is seeded only-if-absent by the launch
|
||||
* path; creating it here first would silently cancel that seed. A
|
||||
* later (idempotent) reinstall upserts trust once the seed lands. */
|
||||
deferTrustUntilConfigToml?: boolean
|
||||
}
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
const codexHomeBase =
|
||||
options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex`
|
||||
const remoteConfigPath = `${codexHomeBase}/hooks.json`
|
||||
const remoteTomlPath = `${codexHomeBase}/config.toml`
|
||||
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
|
||||
try {
|
||||
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
|
||||
@@ -1245,7 +1261,17 @@ export class CodexHookService {
|
||||
// Preserve non-Orca top-level metadata while replacing the hooks tree.
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, { ...config, hooks: nextHooks })
|
||||
try {
|
||||
const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? ''
|
||||
const existingTomlRaw = await readTextFileRemote(sftp, remoteTomlPath)
|
||||
if (existingTomlRaw === null && options?.deferTrustUntilConfigToml === true) {
|
||||
return {
|
||||
agent: 'codex',
|
||||
state: 'installed',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: true,
|
||||
detail: 'Trust entries deferred until config.toml is seeded by the launch path'
|
||||
}
|
||||
}
|
||||
const existingToml = existingTomlRaw ?? ''
|
||||
const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries)
|
||||
if (updatedToml !== existingToml) {
|
||||
await writeTextFileRemoteAtomic(sftp, remoteTomlPath, updatedToml)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
branchHasUpstream,
|
||||
probeBranchUpstream,
|
||||
renameCurrentBranch,
|
||||
resolveUniqueBranchName,
|
||||
type GitExec
|
||||
@@ -12,8 +12,8 @@ const noUpstreamError = new Error(
|
||||
' git push --set-upstream origin feature'
|
||||
)
|
||||
|
||||
describe('branchHasUpstream', () => {
|
||||
it('is true when @{u} resolves to a tracking ref', async () => {
|
||||
describe('probeBranchUpstream', () => {
|
||||
it('reports has-upstream when @{u} resolves to a tracking ref', async () => {
|
||||
const exec: GitExec = vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'feature\n', stderr: '' }
|
||||
@@ -23,10 +23,10 @@ describe('branchHasUpstream', () => {
|
||||
}
|
||||
throw new Error(`unexpected git args: ${args.join(' ')}`)
|
||||
})
|
||||
expect(await branchHasUpstream(exec)).toBe(true)
|
||||
expect(await probeBranchUpstream(exec)).toEqual({ outcome: 'has-upstream' })
|
||||
})
|
||||
|
||||
it('is false when there is no upstream', async () => {
|
||||
it('reports no-upstream when there is no upstream', async () => {
|
||||
const exec: GitExec = vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'feature\n', stderr: '' }
|
||||
@@ -39,10 +39,10 @@ describe('branchHasUpstream', () => {
|
||||
}
|
||||
throw new Error(`unexpected git args: ${args.join(' ')}`)
|
||||
})
|
||||
expect(await branchHasUpstream(exec)).toBe(false)
|
||||
expect(await probeBranchUpstream(exec)).toEqual({ outcome: 'no-upstream' })
|
||||
})
|
||||
|
||||
it('is true when a same-name origin tracking ref exists without configured upstream', async () => {
|
||||
it('reports has-upstream when a same-name origin tracking ref exists without configured upstream', async () => {
|
||||
const exec: GitExec = vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'feature\n', stderr: '' }
|
||||
@@ -55,12 +55,44 @@ describe('branchHasUpstream', () => {
|
||||
}
|
||||
throw new Error(`unexpected git args: ${args.join(' ')}`)
|
||||
})
|
||||
expect(await branchHasUpstream(exec)).toBe(true)
|
||||
expect(await probeBranchUpstream(exec)).toEqual({ outcome: 'has-upstream' })
|
||||
})
|
||||
|
||||
it('is conservatively true on an unexpected failure', async () => {
|
||||
it('reports probe-failed on an unexpected failure', async () => {
|
||||
const exec: GitExec = vi.fn().mockRejectedValue(new Error('fatal: not a git repository'))
|
||||
expect(await branchHasUpstream(exec)).toBe(true)
|
||||
expect(await probeBranchUpstream(exec)).toEqual({
|
||||
outcome: 'probe-failed',
|
||||
message: 'fatal: not a git repository'
|
||||
})
|
||||
})
|
||||
|
||||
it('scrubs credential-bearing URLs from the probe-failed message', async () => {
|
||||
// The message surfaces on the worktree card, so an embedded remote URL
|
||||
// must not leak a token or password into the UI.
|
||||
const exec: GitExec = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error('fatal: unable to access https://user:hunter2@example.com/repo.git/: timed out')
|
||||
)
|
||||
expect(await probeBranchUpstream(exec)).toEqual({
|
||||
outcome: 'probe-failed',
|
||||
message: 'fatal: unable to access https://example.com/repo.git/: timed out'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports probe-failed, not has-upstream, for localized git diagnostics (issue #7808)', async () => {
|
||||
// A gettext-enabled git under de_DE translates even the `fatal:` prefix.
|
||||
const exec: GitExec = vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'feature\n', stderr: '' }
|
||||
}
|
||||
throw new Error(
|
||||
'Command failed: git rev-parse --abbrev-ref HEAD@{u}\n' +
|
||||
"Schwerwiegend: Kein Upstream-Branch für Branch 'feature' konfiguriert."
|
||||
)
|
||||
})
|
||||
const probe = await probeBranchUpstream(exec)
|
||||
expect(probe.outcome).toBe('probe-failed')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isNoUpstreamError } from '../../shared/git-remote-error'
|
||||
import { isNoUpstreamError, stripCredentialsFromMessage } from '../../shared/git-remote-error'
|
||||
import {
|
||||
resolveEffectiveGitUpstream,
|
||||
type GitCommandRunner
|
||||
@@ -11,22 +11,31 @@ import {
|
||||
*/
|
||||
export type GitExec = GitCommandRunner
|
||||
|
||||
export type BranchUpstreamProbe =
|
||||
| { outcome: 'has-upstream' }
|
||||
| { outcome: 'no-upstream' }
|
||||
| { outcome: 'probe-failed'; message: string }
|
||||
|
||||
/**
|
||||
* True when the branch has a configured upstream — i.e. it has been pushed or
|
||||
* is tracking a remote. Auto-rename refuses to touch such a branch because
|
||||
* `git branch -m` would orphan the remote branch and break any open PR.
|
||||
* Whether the branch has an upstream — i.e. it has been pushed or is tracking
|
||||
* a remote. Auto-rename refuses to touch such a branch because `git branch -m`
|
||||
* would orphan the remote branch and break any open PR.
|
||||
*/
|
||||
export async function branchHasUpstream(exec: GitExec): Promise<boolean> {
|
||||
export async function probeBranchUpstream(exec: GitExec): Promise<BranchUpstreamProbe> {
|
||||
try {
|
||||
return (await resolveEffectiveGitUpstream(exec)) !== null
|
||||
const upstream = await resolveEffectiveGitUpstream(exec)
|
||||
return { outcome: upstream !== null ? 'has-upstream' : 'no-upstream' }
|
||||
} catch (error) {
|
||||
if (isNoUpstreamError(error)) {
|
||||
return false
|
||||
return { outcome: 'no-upstream' }
|
||||
}
|
||||
// Why: an unexpected failure is not proof either way — report it as its own
|
||||
// outcome so callers skip the rename but stay retryable (issue #7808).
|
||||
// The message surfaces in the UI, so scrub credential-bearing remote URLs.
|
||||
return {
|
||||
outcome: 'probe-failed',
|
||||
message: stripCredentialsFromMessage(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
// Why: an unexpected failure (detached HEAD, corruption, transport error)
|
||||
// is not proof there's no upstream. Stay conservative and report "has
|
||||
// upstream" so the caller skips the rename rather than risk a published branch.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
nonInteractiveGitEnv,
|
||||
parseRetryAfterMs,
|
||||
promptGuardGitEnv,
|
||||
redirectPortedHostnameToEnv
|
||||
redirectPortedHostnameToEnv,
|
||||
untranslatedGitOutputEnv
|
||||
} from './runner'
|
||||
|
||||
// Reads git config injected via the GIT_CONFIG_COUNT/KEY/VALUE env protocol
|
||||
@@ -218,3 +219,25 @@ describe('nonInteractiveGitEnv credential-interactivity disable (STA-1292)', ()
|
||||
expect(env.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('git env forces untranslated diagnostics (issue #7808)', () => {
|
||||
it('overrides an inherited non-English locale so stderr parsers keep working', () => {
|
||||
// A gettext-enabled git under de_DE translates even the `fatal:` prefix,
|
||||
// breaking isNoUpstreamError and every other stderr phrase match.
|
||||
const env = promptGuardGitEnv({ PATH: '/usr/bin', LC_ALL: 'de_DE.UTF-8' })
|
||||
expect(env.LC_ALL).toBe('en_US.UTF-8')
|
||||
expect(env.LANG).toBe('en_US.UTF-8')
|
||||
})
|
||||
|
||||
it('pins LANGUAGE, which outranks LC_ALL in gettext lookups', () => {
|
||||
const env = untranslatedGitOutputEnv({ PATH: '/usr/bin', LANGUAGE: 'de:en' })
|
||||
expect(env.LANGUAGE).toBe('en')
|
||||
expect(env.LC_ALL).toBe('en_US.UTF-8')
|
||||
})
|
||||
|
||||
it('applies to nonInteractiveGitEnv as well', () => {
|
||||
const env = nonInteractiveGitEnv({ PATH: '/usr/bin', LANG: 'fr_FR.UTF-8' })
|
||||
expect(env.LC_ALL).toBe('en_US.UTF-8')
|
||||
expect(env.LANGUAGE).toBe('en')
|
||||
})
|
||||
})
|
||||
|
||||
+30
-4
@@ -35,9 +35,17 @@ import {
|
||||
escapeWslShCommandForWindows,
|
||||
quotePosixShell
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale'
|
||||
|
||||
// ─── Core resolution ────────────────────────────────────────────────
|
||||
|
||||
// `LANGUAGE=en LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8` — env-assignment prefix for
|
||||
// WSL-routed git, where spawn env cannot cross the wsl.exe boundary. Values are
|
||||
// shell-safe unquoted (alnum, dot, dash, underscore only).
|
||||
const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(' ')
|
||||
|
||||
type ResolvedCommand = {
|
||||
binary: string
|
||||
args: string[]
|
||||
@@ -202,6 +210,10 @@ function resolveCommand(
|
||||
}
|
||||
|
||||
const translatedArgs = translateArgsForWsl(args)
|
||||
// Why: env set on wsl.exe stays on the Windows side (WSLENV forwards only
|
||||
// named vars), so the untranslated-output locale must ride the command
|
||||
// string for git stderr parsers to work inside the distro (issue #7808).
|
||||
const localePrefix = command === 'git' ? `${GIT_OUTPUT_LOCALE_SHELL_PREFIX} ` : ''
|
||||
const escapedCommand = quotePosixShell(command)
|
||||
// Why: shell-escape each argument to prevent word splitting / glob expansion
|
||||
// inside the bash -c string. Single quotes are safe for all chars except
|
||||
@@ -214,8 +226,8 @@ function resolveCommand(
|
||||
// doesn't need a particular cwd for global calls like `api rate_limit`.
|
||||
const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null)
|
||||
const shellCmd = linuxCwd
|
||||
? `cd ${quotePosixShell(linuxCwd)} && ${escapedCommand} ${escapedArgs.join(' ')}`
|
||||
: `${escapedCommand} ${escapedArgs.join(' ')}`
|
||||
? `cd ${quotePosixShell(linuxCwd)} && ${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}`
|
||||
: `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}`
|
||||
|
||||
if (options.useWslLoginShell) {
|
||||
return {
|
||||
@@ -560,10 +572,21 @@ export function appendGitConfigEnv(
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin Orca-spawned git to untranslated English output so stderr/progress
|
||||
* parsers keep working under any user locale (issue #7808; see
|
||||
* UNTRANSLATED_GIT_OUTPUT_ENV for the full rationale). Terminal git is
|
||||
* untouched. Injected by every git runner in this module; WSL-routed spawns
|
||||
* get the same values via GIT_OUTPUT_LOCALE_SHELL_PREFIX instead.
|
||||
*/
|
||||
export function untranslatedGitOutputEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
return { ...env, ...UNTRANSLATED_GIT_OUTPUT_ENV }
|
||||
}
|
||||
|
||||
export function promptGuardGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
return appendGitConfigEnv(
|
||||
{
|
||||
...env,
|
||||
...untranslatedGitOutputEnv(env),
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_ASKPASS: env.GIT_ASKPASS ?? '',
|
||||
SSH_ASKPASS: env.SSH_ASKPASS ?? '',
|
||||
@@ -870,7 +893,8 @@ export async function gitExecFileAsyncBuffer(
|
||||
const { stdout } = (await execFileCapture(resolved.binary, resolved.args, {
|
||||
cwd: resolved.cwd,
|
||||
encoding: 'buffer',
|
||||
maxBuffer: options.maxBuffer
|
||||
maxBuffer: options.maxBuffer,
|
||||
env: untranslatedGitOutputEnv()
|
||||
})) as { stdout: Buffer }
|
||||
return { stdout }
|
||||
}
|
||||
@@ -1051,6 +1075,7 @@ export function gitExecFileSync(
|
||||
return execFileSync(resolved.binary, resolved.args, {
|
||||
cwd: resolved.cwd,
|
||||
encoding: options.encoding ?? 'utf-8',
|
||||
env: untranslatedGitOutputEnv(),
|
||||
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'],
|
||||
timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS
|
||||
}) as string
|
||||
@@ -1076,6 +1101,7 @@ export function gitSpawn(
|
||||
const spawnStartedAt = performance.now()
|
||||
const child = spawn(resolved.binary, resolved.args, {
|
||||
...spawnOptions,
|
||||
env: untranslatedGitOutputEnv(spawnOptions.env ?? process.env),
|
||||
cwd: resolved.cwd
|
||||
})
|
||||
recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getGrokAccountStatus } from './status'
|
||||
import { isGrokAccessTokenFresh, readGrokAuthSession } from '../rate-limits/grok-auth'
|
||||
|
||||
vi.mock('../rate-limits/grok-auth', () => ({
|
||||
isGrokAccessTokenFresh: vi.fn(),
|
||||
readGrokAuthSession: vi.fn()
|
||||
}))
|
||||
|
||||
describe('getGrokAccountStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(isGrokAccessTokenFresh).mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('reports unsigned status when the Grok auth file is missing', () => {
|
||||
vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' })
|
||||
|
||||
expect(getGrokAccountStatus()).toEqual({
|
||||
signedIn: false,
|
||||
email: null,
|
||||
teamId: null,
|
||||
tokenFresh: false,
|
||||
error: null
|
||||
})
|
||||
})
|
||||
|
||||
it('reports auth read errors without exposing token fields', () => {
|
||||
vi.mocked(readGrokAuthSession).mockReturnValue({
|
||||
status: 'error',
|
||||
error: 'Grok auth file is invalid'
|
||||
})
|
||||
|
||||
expect(getGrokAccountStatus()).toEqual({
|
||||
signedIn: false,
|
||||
email: null,
|
||||
teamId: null,
|
||||
tokenFresh: false,
|
||||
error: 'Grok auth file is invalid'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns non-secret signed-in metadata and freshness', () => {
|
||||
vi.mocked(readGrokAuthSession).mockReturnValue({
|
||||
status: 'ok',
|
||||
session: {
|
||||
accessToken: 'secret-token',
|
||||
email: 'dev@example.com',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
expiresAtMs: null,
|
||||
oidcClientId: 'client-1'
|
||||
}
|
||||
})
|
||||
vi.mocked(isGrokAccessTokenFresh).mockReturnValue(false)
|
||||
|
||||
const status = getGrokAccountStatus()
|
||||
|
||||
expect(status).toEqual({
|
||||
signedIn: true,
|
||||
email: 'dev@example.com',
|
||||
teamId: 'team-1',
|
||||
tokenFresh: false,
|
||||
error: null
|
||||
})
|
||||
expect(JSON.stringify(status)).not.toContain('secret-token')
|
||||
expect(JSON.stringify(status)).not.toContain('client-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { GrokAccountStatus } from '../../shared/rate-limit-types'
|
||||
import { isGrokAccessTokenFresh, readGrokAuthSession } from '../rate-limits/grok-auth'
|
||||
|
||||
export function getGrokAccountStatus(): GrokAccountStatus {
|
||||
const readResult = readGrokAuthSession()
|
||||
if (readResult.status === 'missing') {
|
||||
return {
|
||||
signedIn: false,
|
||||
email: null,
|
||||
teamId: null,
|
||||
tokenFresh: false,
|
||||
error: null
|
||||
}
|
||||
}
|
||||
if (readResult.status === 'error') {
|
||||
return {
|
||||
signedIn: false,
|
||||
email: null,
|
||||
teamId: null,
|
||||
tokenFresh: false,
|
||||
error: readResult.error
|
||||
}
|
||||
}
|
||||
const session = readResult.session
|
||||
return {
|
||||
signedIn: true,
|
||||
email: session.email,
|
||||
teamId: session.teamId,
|
||||
tokenFresh: isGrokAccessTokenFresh(session),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ import {
|
||||
} from './claude-accounts/live-pty-gate'
|
||||
import { StarNagService } from './star-nag/service'
|
||||
import { agentHookServer } from './agent-hooks/server'
|
||||
import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager'
|
||||
import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename'
|
||||
import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename'
|
||||
import { moveWorktree } from './git/worktree'
|
||||
@@ -2257,6 +2258,9 @@ app.on('will-quit', (e) => {
|
||||
automations?.stop()
|
||||
setUnreadDockBadgeCount(0)
|
||||
agentHookServer.stop()
|
||||
// Why: cancels relay restart/reinstall timers and kills wsl.exe children
|
||||
// deterministically instead of relying on stdio-pipe teardown.
|
||||
wslHookRelayManager.disposeAll()
|
||||
stats?.flush()
|
||||
// Why: agent-browser daemon processes would otherwise linger after Orca quits,
|
||||
// holding ports and leaving stale session state on disk.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join, sep } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
@@ -6,6 +8,7 @@ import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scanAiVaultSessions: vi.fn(),
|
||||
scanRemoteAiVaultSessions: vi.fn(),
|
||||
listClaudeSubagentSessions: vi.fn(),
|
||||
scanRuntimeAiVaultSessions: vi.fn(),
|
||||
getAiVaultWslHomeDirs: vi.fn(),
|
||||
getSshFilesystemProvider: vi.fn(),
|
||||
@@ -26,6 +29,10 @@ vi.mock('../ai-vault/remote-session-scanner', () => ({
|
||||
scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions
|
||||
}))
|
||||
|
||||
vi.mock('../ai-vault/session-scanner-claude-subagents', () => ({
|
||||
listClaudeSubagentSessions: mocks.listClaudeSubagentSessions
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', () => ({
|
||||
getWslHomeAsync: mocks.getAiVaultWslHomeDirs,
|
||||
listWslDistrosAsync: vi.fn().mockResolvedValue([])
|
||||
@@ -53,6 +60,7 @@ beforeEach(() => {
|
||||
mocks.scanRemoteAiVaultSessions.mockResolvedValue(
|
||||
result([session('ssh:dev-box', 'remote-session')])
|
||||
)
|
||||
mocks.listClaudeSubagentSessions.mockResolvedValue({ sessions: [], issues: [] })
|
||||
mocks.scanRuntimeAiVaultSessions.mockResolvedValue(
|
||||
result([session('runtime:remote-server', 'runtime-session')])
|
||||
)
|
||||
@@ -198,6 +206,82 @@ describe('listAiVaultSessions host routing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('listAiVaultSubagentSessions gating', () => {
|
||||
const claudeRoot = join(homedir(), '.claude', 'projects')
|
||||
|
||||
it('lists subagents for a local Claude session inside the projects root', async () => {
|
||||
const parentFilePath = join(claudeRoot, 'proj', 'sess.jsonl')
|
||||
|
||||
await _internals.listAiVaultSubagentSessions({
|
||||
agent: 'claude',
|
||||
parentFilePath,
|
||||
executionHostId: 'local'
|
||||
})
|
||||
|
||||
expect(mocks.listClaudeSubagentSessions).toHaveBeenCalledWith({ parentFilePath })
|
||||
})
|
||||
|
||||
it('returns empty for a remote Claude session without reading the filesystem', async () => {
|
||||
const result = await _internals.listAiVaultSubagentSessions({
|
||||
agent: 'claude',
|
||||
parentFilePath: join(claudeRoot, 'proj', 'sess.jsonl'),
|
||||
executionHostId: 'ssh:dev-box'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ sessions: [], issues: [] })
|
||||
expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a path outside the Claude projects root', async () => {
|
||||
const result = await _internals.listAiVaultSubagentSessions({
|
||||
agent: 'claude',
|
||||
parentFilePath: '/etc/secrets/subagents',
|
||||
executionHostId: 'local'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ sessions: [], issues: [] })
|
||||
expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a dot-segment traversal out of the Claude projects root', async () => {
|
||||
// Built with sep (not join) so the `..` segments survive into the arg.
|
||||
const traversal = [claudeRoot, '..', '..', '..', 'etc', 'passwd.jsonl'].join(sep)
|
||||
|
||||
const result = await _internals.listAiVaultSubagentSessions({
|
||||
agent: 'claude',
|
||||
parentFilePath: traversal,
|
||||
executionHostId: 'local'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ sessions: [], issues: [] })
|
||||
expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves empty for malformed IPC payloads instead of throwing', async () => {
|
||||
const missing = await _internals.listAiVaultSubagentSessions(undefined)
|
||||
const badPath = await _internals.listAiVaultSubagentSessions({
|
||||
agent: 'claude',
|
||||
parentFilePath: 42 as unknown as string,
|
||||
executionHostId: 'local'
|
||||
})
|
||||
|
||||
expect(missing).toEqual({ sessions: [], issues: [] })
|
||||
expect(badPath).toEqual({ sessions: [], issues: [] })
|
||||
expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty for a non-Claude agent', async () => {
|
||||
const result = await _internals.listAiVaultSubagentSessions({
|
||||
agent: 'codex',
|
||||
parentFilePath: join(claudeRoot, 'proj', 'sess.jsonl'),
|
||||
executionHostId: 'local'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ sessions: [], issues: [] })
|
||||
expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
function hostInfo(targetId: string) {
|
||||
return {
|
||||
targetId,
|
||||
@@ -239,6 +323,7 @@ function session(
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: `codex resume ${sessionId}`
|
||||
resumeCommand: `codex resume ${sessionId}`,
|
||||
subagent: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner'
|
||||
import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents'
|
||||
import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-discovery'
|
||||
import { scanAiVaultSessions } from '../ai-vault/session-scanner'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results'
|
||||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultListArgs,
|
||||
AiVaultListResult,
|
||||
AiVaultSubagentListArgs,
|
||||
AiVaultSubagentListResult
|
||||
} from '../../shared/ai-vault-types'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostScope,
|
||||
@@ -263,6 +271,11 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
|
||||
ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) =>
|
||||
listAiVaultSessions(args)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'aiVault:listSubagentSessions',
|
||||
(_event, args?: AiVaultSubagentListArgs): Promise<AiVaultSubagentListResult> =>
|
||||
listAiVaultSubagentSessions(args)
|
||||
)
|
||||
// DOM focus/visibility events don't fire in the renderer on macOS app
|
||||
// activation, so refresh-on-refocus needs this main-process signal.
|
||||
app.on('browser-window-focus', (_event, window) => {
|
||||
@@ -272,6 +285,40 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
|
||||
})
|
||||
}
|
||||
|
||||
// Provider-gated: only Claude materializes Task subagent transcripts as
|
||||
// sibling files today; other agents resolve to an empty list.
|
||||
async function listAiVaultSubagentSessions(
|
||||
args?: AiVaultSubagentListArgs
|
||||
): Promise<AiVaultSubagentListResult> {
|
||||
// IPC payloads are untyped at runtime; malformed input resolves empty like
|
||||
// every other rejected input instead of throwing.
|
||||
if (
|
||||
!args ||
|
||||
args.agent !== 'claude' ||
|
||||
typeof args.parentFilePath !== 'string' ||
|
||||
!args.parentFilePath.trim()
|
||||
) {
|
||||
return { sessions: [], issues: [] }
|
||||
}
|
||||
// Why: subagent transcripts are read from the local filesystem. The UI
|
||||
// skips remote sessions (their transcripts live on the remote host); return
|
||||
// empty defensively rather than reading local paths for a remote session.
|
||||
const executionHostId = args.executionHostId ?? LOCAL_EXECUTION_HOST_ID
|
||||
if (executionHostId !== LOCAL_EXECUTION_HOST_ID) {
|
||||
return { sessions: [], issues: [] }
|
||||
}
|
||||
// Why: the path is renderer-supplied; only list files under a known Claude
|
||||
// projects root so a crafted path can't readdir/preview arbitrary dirs.
|
||||
// resolve() collapses `..` segments first — isPathInsideOrEqual compares
|
||||
// textually and would otherwise pass `<root>/../../etc/x.jsonl`.
|
||||
const parentFilePath = resolve(args.parentFilePath)
|
||||
const roots = claudeProjectsRootDirs({ wslHomeDirs: await getAiVaultWslHomeDirs() })
|
||||
if (!roots.some((root) => isPathInsideOrEqual(resolve(root), parentFilePath))) {
|
||||
return { sessions: [], issues: [] }
|
||||
}
|
||||
return listClaudeSubagentSessions({ parentFilePath })
|
||||
}
|
||||
|
||||
function resetAiVaultCacheForTests(): void {
|
||||
cachedList = null
|
||||
inflightList = null
|
||||
@@ -281,6 +328,7 @@ function resetAiVaultCacheForTests(): void {
|
||||
|
||||
export const _internals = {
|
||||
listAiVaultSessions,
|
||||
listAiVaultSubagentSessions,
|
||||
resetAiVaultCacheForTests
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { getGrokAccountStatus } from '../grok-accounts/status'
|
||||
|
||||
export function registerGrokAccountHandlers(): void {
|
||||
ipcMain.handle('grokAccounts:getStatus', () => getGrokAccountStatus())
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
getFirstCommandToken
|
||||
} from '../../shared/command-token-scanner'
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
|
||||
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
|
||||
import { piTitlebarExtensionService } from '../pi/titlebar-extension-service'
|
||||
import { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi-agent-kind'
|
||||
@@ -508,6 +509,9 @@ export type BuildPtyHostEnvOptions = {
|
||||
launchCommand?: string
|
||||
shellPath?: string
|
||||
isWsl?: boolean
|
||||
/** Distro for WSL spawns (null = Windows default distro). Drives the WSL
|
||||
* hook relay ensure + guest endpoint repoint; only read when isWsl. */
|
||||
wslDistro?: string | null
|
||||
agentStatusHooksEnabled: boolean
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
}
|
||||
@@ -865,6 +869,19 @@ export function buildPtyHostEnv(
|
||||
}
|
||||
if (opts.agentStatusHooksEnabled) {
|
||||
Object.assign(baseEnv, agentHookServer.buildPtyEnv())
|
||||
if (opts.isWsl === true) {
|
||||
// Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace. Ensure
|
||||
// the guest-resident relay for this distro (covers fresh spawns and
|
||||
// post-restart reattach re-spawns), and once the relay has reported the
|
||||
// guest home, point restart re-coordination at the relay-written
|
||||
// guest-side endpoint file instead of the /p-translated Windows one.
|
||||
const distro = opts.wslDistro ?? null
|
||||
wslHookRelayManager.ensureForDistro(distro)
|
||||
const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro)
|
||||
if (guestEndpoint) {
|
||||
baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: PI_CODING_AGENT_DIR owns Pi's / OMP's full config/session root. Keep
|
||||
@@ -1327,6 +1344,7 @@ export function registerPtyHandlers(
|
||||
launchCommand: ctx?.command,
|
||||
shellPath: ctx?.shellPath,
|
||||
isWsl: ctx?.isWsl,
|
||||
wslDistro: ctx?.wslDistro ?? null,
|
||||
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
||||
networkProxySettings: getSettings?.()
|
||||
})
|
||||
@@ -2187,6 +2205,7 @@ export function registerPtyHandlers(
|
||||
launchCommand: args.command,
|
||||
shellPath: daemonShellOverride ?? process.env.COMSPEC,
|
||||
isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd),
|
||||
wslDistro: codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
|
||||
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
||||
networkProxySettings: getSettings?.()
|
||||
})
|
||||
@@ -2960,6 +2979,8 @@ export function registerPtyHandlers(
|
||||
launchCommand: args.command,
|
||||
shellPath: effectiveShellOverride ?? process.env.COMSPEC,
|
||||
isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd),
|
||||
wslDistro:
|
||||
codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
|
||||
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
||||
networkProxySettings: getSettings?.()
|
||||
})
|
||||
|
||||
@@ -16,11 +16,17 @@ import { registerRateLimitHandlers } from './rate-limits'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { RateLimitState } from '../../shared/rate-limit-types'
|
||||
|
||||
function makeService(): { service: RateLimitService; refresh: ReturnType<typeof vi.fn> } {
|
||||
function makeService(): {
|
||||
service: RateLimitService
|
||||
refresh: ReturnType<typeof vi.fn>
|
||||
refreshGrok: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
|
||||
const refreshGrok = vi.fn(() => Promise.resolve({} as RateLimitState))
|
||||
const service = {
|
||||
getState: vi.fn(() => ({}) as RateLimitState),
|
||||
refresh,
|
||||
refreshGrok,
|
||||
refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
|
||||
refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
|
||||
consumeCodexRateLimitResetCredit: vi.fn(() =>
|
||||
@@ -30,7 +36,7 @@ function makeService(): { service: RateLimitService; refresh: ReturnType<typeof
|
||||
fetchInactiveClaudeAccountsOnOpen: vi.fn(() => Promise.resolve()),
|
||||
fetchInactiveCodexAccountsOnOpen: vi.fn(() => Promise.resolve())
|
||||
}
|
||||
return { service: service as unknown as RateLimitService, refresh }
|
||||
return { service: service as unknown as RateLimitService, refresh, refreshGrok }
|
||||
}
|
||||
|
||||
describe('registerRateLimitHandlers', () => {
|
||||
@@ -53,5 +59,15 @@ describe('registerRateLimitHandlers', () => {
|
||||
expect(ipcState.handleHandlers.has('rateLimits:get')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:refreshGrok')).toBe(true)
|
||||
})
|
||||
|
||||
it('registers a refreshGrok channel that delegates to refreshGrok()', async () => {
|
||||
const { service, refreshGrok } = makeService()
|
||||
registerRateLimitHandlers(service)
|
||||
const handler = ipcState.handleHandlers.get('rateLimits:refreshGrok')
|
||||
expect(handler).toBeDefined()
|
||||
await handler!({})
|
||||
expect(refreshGrok).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,4 +24,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void {
|
||||
rateLimits.fetchInactiveCodexAccountsOnOpen()
|
||||
)
|
||||
ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh())
|
||||
ipcMain.handle('rateLimits:refreshGrok', () => rateLimits.refreshGrok())
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ const {
|
||||
registerAgentTrustHandlersMock,
|
||||
registerClaudeAccountHandlersMock,
|
||||
registerMiniMaxCredentialsHandlersMock,
|
||||
registerGrokAccountHandlersMock,
|
||||
registerClipboardHandlersMock,
|
||||
setTrustedClipboardRendererWebContentsIdMock,
|
||||
registerUpdaterHandlersMock,
|
||||
@@ -97,6 +98,7 @@ const {
|
||||
registerAgentTrustHandlersMock: vi.fn(),
|
||||
registerClaudeAccountHandlersMock: vi.fn(),
|
||||
registerMiniMaxCredentialsHandlersMock: vi.fn(),
|
||||
registerGrokAccountHandlersMock: vi.fn(),
|
||||
registerClipboardHandlersMock: vi.fn(),
|
||||
setTrustedClipboardRendererWebContentsIdMock: vi.fn(),
|
||||
registerUpdaterHandlersMock: vi.fn(),
|
||||
@@ -309,6 +311,10 @@ vi.mock('./minimax-credentials', () => ({
|
||||
registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./grok-accounts', () => ({
|
||||
registerGrokAccountHandlers: registerGrokAccountHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('../window/attach-main-window-services', () => ({
|
||||
registerUpdaterHandlers: registerUpdaterHandlersMock
|
||||
}))
|
||||
@@ -464,6 +470,7 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerPetHandlersMock).toHaveBeenCalled()
|
||||
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
|
||||
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
expect(registerGrokAccountHandlersMock).toHaveBeenCalled()
|
||||
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats)
|
||||
expect(registerLinearHandlersMock).toHaveBeenCalled()
|
||||
|
||||
@@ -56,6 +56,7 @@ import { registerAgentHookHandlers } from './agent-hooks'
|
||||
import { registerAgentTrustHandlers } from './agent-trust'
|
||||
import { registerClaudeAccountHandlers } from './claude-accounts'
|
||||
import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
|
||||
import { registerGrokAccountHandlers } from './grok-accounts'
|
||||
import { registerUpdaterHandlers } from '../window/attach-main-window-services'
|
||||
import {
|
||||
registerClipboardHandlers,
|
||||
@@ -125,6 +126,7 @@ export function registerCoreHandlers(
|
||||
registerAgentTrustHandlers()
|
||||
registerClaudeAccountHandlers(claudeAccounts)
|
||||
registerMiniMaxCredentialsHandlers(rateLimits)
|
||||
registerGrokAccountHandlers()
|
||||
registerRateLimitHandlers(rateLimits)
|
||||
registerGitHubHandlers(store, stats)
|
||||
registerGitLabHandlers(store)
|
||||
|
||||
@@ -306,6 +306,24 @@ describe('registerSettingsHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes terminal line height updates before persistence', async () => {
|
||||
store.getSettings.mockReturnValue({ terminalLineHeight: 1 })
|
||||
store.updateSettings.mockReturnValue({ terminalLineHeight: 1 })
|
||||
registerSettingsHandlers(store as never)
|
||||
|
||||
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
|
||||
_event: unknown,
|
||||
args: unknown
|
||||
) => Promise<unknown>
|
||||
|
||||
await handler(settingsInvokeEvent, { terminalLineHeight: 0.85 })
|
||||
|
||||
expect(store.updateSettings).toHaveBeenCalledWith(
|
||||
{ terminalLineHeight: 1 },
|
||||
{ notifyListeners: true, originWebContentsId: 1 }
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes custom terminal themes from renderer settings IPC', async () => {
|
||||
store.getSettings.mockReturnValue({ terminalCustomThemes: [] })
|
||||
store.updateSettings.mockReturnValue({ terminalCustomThemes: [] })
|
||||
|
||||
@@ -18,6 +18,7 @@ import { normalizeUiLanguage } from '../../shared/ui-language'
|
||||
import { applyAppIcon } from '../app-icon'
|
||||
import { normalizeTerminalCustomThemes } from '../../shared/terminal-custom-themes'
|
||||
import { normalizeDesktopTerminalScrollbackRows } from '../../shared/terminal-scrollback-policy'
|
||||
import { normalizeTerminalLineHeight } from '../../shared/terminal-line-height-settings'
|
||||
import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation'
|
||||
import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher'
|
||||
|
||||
@@ -95,6 +96,9 @@ export function registerSettingsHandlers(
|
||||
args.terminalScrollbackRows
|
||||
)
|
||||
}
|
||||
if ('terminalLineHeight' in args) {
|
||||
sanitizedArgs.terminalLineHeight = normalizeTerminalLineHeight(args.terminalLineHeight)
|
||||
}
|
||||
if ('uiLanguage' in args) {
|
||||
sanitizedArgs.uiLanguage = normalizeUiLanguage(args.uiLanguage)
|
||||
}
|
||||
|
||||
@@ -476,6 +476,34 @@ describe('scanOpenCodeUsageDatabases', () => {
|
||||
expect(sessionOne?.eventCount).toBe(1)
|
||||
})
|
||||
|
||||
it('reuses a fully-duplicate backup instead of reparsing it when the live db changes', async () => {
|
||||
// The backup only holds stale copies of sessions the canonical db already
|
||||
// owns, so it owns nothing. When the live db changes it must not be
|
||||
// demoted back into the parse set — there is no claim to reclaim.
|
||||
const canonicalPath = writeSessionTotalsDb('opencode.db', [['session-1', 1000]])
|
||||
writeSessionTotalsDb('opencode-backup.db', [['session-1', 400]])
|
||||
|
||||
const first = await scanOpenCodeUsageDatabases([], [])
|
||||
const firstBackup = first.processedDatabases.find((database) =>
|
||||
database.path.endsWith('opencode-backup.db')
|
||||
)
|
||||
expect(firstBackup?.ownedSessionIds).toEqual([])
|
||||
|
||||
const db = new Database(canonicalPath)
|
||||
insertSessionTotalsRow(db, 'session-2', 200)
|
||||
db.close()
|
||||
|
||||
const second = await scanOpenCodeUsageDatabases([], first.processedDatabases)
|
||||
const secondBackup = second.processedDatabases.find((database) =>
|
||||
database.path.endsWith('opencode-backup.db')
|
||||
)
|
||||
// A reused cache entry is the same object; a reparse would produce a new one.
|
||||
expect(secondBackup).toBe(firstBackup)
|
||||
expect(
|
||||
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
|
||||
).toBe(1200)
|
||||
})
|
||||
|
||||
it('lets the live database reclaim sessions after a sticky backup-only claim', async () => {
|
||||
// First scan only has the backup (e.g. live db temporarily missing), so it
|
||||
// owns session-1 at the stale snapshot. When opencode.db reappears with
|
||||
|
||||
@@ -935,11 +935,16 @@ export async function scanOpenCodeUsageDatabases(
|
||||
|
||||
// Why: a sticky backup claim from a scan where opencode.db was missing would
|
||||
// otherwise freeze a still-growing session at the backup snapshot when the
|
||||
// live db reappears. Reparse lower-priority siblings whenever a higher-
|
||||
// priority path is being re-evaluated so the live db can reclaim sessions
|
||||
// without double-counting cached backup aggregates.
|
||||
// live db reappears. Reparse a lower-priority sibling only when it still owns
|
||||
// sessions a higher-priority path could reclaim; a sibling that owns nothing
|
||||
// (the common case once the live db has claimed every shared session) has no
|
||||
// claim to give back, so reparsing it every time the live db changes is pure
|
||||
// work.
|
||||
const demotedReusePaths: string[] = []
|
||||
for (const dbPath of reusedByPath.keys()) {
|
||||
for (const [dbPath, reused] of reusedByPath) {
|
||||
if ((reused.ownedSessionIds?.length ?? 0) === 0) {
|
||||
continue
|
||||
}
|
||||
const higherPriorityParsing = pathsToParse.some(
|
||||
(candidate) => compareOpenCodeClaimPriority(candidate, dbPath) < 0
|
||||
)
|
||||
|
||||
@@ -535,6 +535,20 @@ describe('Store', () => {
|
||||
expect(settings.notifications.suppressWhenFocused).toBe(true)
|
||||
})
|
||||
|
||||
it('repairs a persisted terminal line height outside xterm bounds', async () => {
|
||||
const persisted = getDefaultPersistedState(testState.dir)
|
||||
writeDataFile({
|
||||
...persisted,
|
||||
settings: { ...persisted.settings, terminalLineHeight: 0.85 }
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getSettings().terminalLineHeight).toBe(1)
|
||||
store.flush()
|
||||
expect((readDataFile() as PersistedState).settings.terminalLineHeight).toBe(1)
|
||||
})
|
||||
|
||||
it('returns default UI state when no data file exists', async () => {
|
||||
const store = await createStore()
|
||||
const ui = store.getUI()
|
||||
|
||||
@@ -203,6 +203,7 @@ import {
|
||||
normalizeTuiAgentEnvRecord
|
||||
} from '../shared/tui-agent-launch-defaults'
|
||||
import { normalizeTerminalCursorStyleDefault } from '../shared/terminal-cursor-style-settings'
|
||||
import { normalizeTerminalLineHeight } from '../shared/terminal-line-height-settings'
|
||||
import { normalizeUiLanguage } from '../shared/ui-language'
|
||||
import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom'
|
||||
import { persistedUIValuesEqual } from '../shared/persisted-ui-equality'
|
||||
@@ -2987,6 +2988,15 @@ export class Store {
|
||||
parsed.settings
|
||||
)
|
||||
const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings)
|
||||
const migratedTerminalLineHeight = normalizeTerminalLineHeight(
|
||||
parsed.settings?.terminalLineHeight
|
||||
)
|
||||
if (
|
||||
parsed.settings?.terminalLineHeight !== undefined &&
|
||||
parsed.settings.terminalLineHeight !== migratedTerminalLineHeight
|
||||
) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
const rawTaskProviderSettings = normalizeTaskProviderSettings({
|
||||
visibleTaskProviders: parsed.settings?.visibleTaskProviders,
|
||||
defaultTaskSource: parsed.settings?.defaultTaskSource
|
||||
@@ -3119,6 +3129,7 @@ export class Store {
|
||||
primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults,
|
||||
...migratedAutoRenameBranchFromWork,
|
||||
...migratedTerminalCursorStyle,
|
||||
terminalLineHeight: migratedTerminalLineHeight,
|
||||
...migratedTerminalTuiScrollSensitivity.settings,
|
||||
experimentalActivity: migratedExperimentalActivity,
|
||||
experimentalActivityDefaultedOffForAllUsers: true,
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
/** Guest-relative layout of Orca's managed WSL CODEX_HOME. Must stay in sync
|
||||
* with getWslRuntimeHomePath (codex-accounts/runtime-home-service.ts), which
|
||||
* builds the UNC twin of this path. */
|
||||
export const WSL_CODEX_RUNTIME_HOME_SEGMENTS = [
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
] as const
|
||||
|
||||
export function wslCodexRuntimeHomeForGuestHome(guestHome: string): string {
|
||||
const home = guestHome.endsWith('/') ? guestHome.slice(0, -1) : guestHome
|
||||
return `${home}/${WSL_CODEX_RUNTIME_HOME_SEGMENTS.join('/')}`
|
||||
}
|
||||
|
||||
export function isHostCodexHomeForWsl(value: string | undefined): boolean {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -47,4 +47,25 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u')
|
||||
})
|
||||
|
||||
it('path-translates a Windows hook endpoint but passes a guest-side one untouched', () => {
|
||||
const windowsEnv: Record<string, string> = {
|
||||
ORCA_AGENT_HOOK_ENDPOINT: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\agent-hooks\\endpoint.cmd'
|
||||
}
|
||||
addOrcaWslInteropEnv(windowsEnv)
|
||||
expect(windowsEnv.WSLENV).toContain('ORCA_AGENT_HOOK_ENDPOINT/p')
|
||||
|
||||
const guestEnv: Record<string, string> = {
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '/home/jin/.orca-wsl/agent-hooks/port-4567/endpoint.env'
|
||||
}
|
||||
addOrcaWslInteropEnv(guestEnv)
|
||||
expect(guestEnv.WSLENV).toContain('ORCA_AGENT_HOOK_ENDPOINT/u')
|
||||
expect(guestEnv.WSLENV).not.toContain('ORCA_AGENT_HOOK_ENDPOINT/p')
|
||||
})
|
||||
|
||||
it('marks the WSL hook relay version for import on relay spawn envs', () => {
|
||||
const env: Record<string, string> = { ORCA_WSL_HOOK_RELAY_VERSION: '0.1.0+abc' }
|
||||
addOrcaWslInteropEnv(env)
|
||||
expect(env.WSLENV).toBe('ORCA_WSL_HOOK_RELAY_VERSION/u')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,10 @@ function upsertWslenvEntry(entries: string[], entry: string): void {
|
||||
|
||||
export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
const entries = parseWslenvEntries(env.WSLENV)
|
||||
// Why: the endpoint is a Windows path (/p-translated so the guest reads it
|
||||
// via /mnt/c) until the WSL hook relay reports the guest home — then it is
|
||||
// already a guest-side POSIX path and must cross untranslated.
|
||||
const endpointFlag = env.ORCA_AGENT_HOOK_ENDPOINT?.startsWith('/') ? 'u' : 'p'
|
||||
// Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start.
|
||||
const passthroughEntries = [
|
||||
'ORCA_TERMINAL_HANDLE/u',
|
||||
@@ -28,7 +32,9 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
'ORCA_AGENT_HOOK_TOKEN/u',
|
||||
'ORCA_AGENT_HOOK_ENV/u',
|
||||
'ORCA_AGENT_HOOK_VERSION/u',
|
||||
'ORCA_AGENT_HOOK_ENDPOINT/p',
|
||||
`ORCA_AGENT_HOOK_ENDPOINT/${endpointFlag}`,
|
||||
'ORCA_WSL_HOOK_RELAY_VERSION/u',
|
||||
'ORCA_WSL_HOOK_INSTANCE/u',
|
||||
'ORCA_OMP_SOURCE_AGENT_DIR/p',
|
||||
'ORCA_OMP_STATUS_EXTENSION/p'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('readGrokAuthSession', () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
vi.doUnmock('node:fs')
|
||||
})
|
||||
|
||||
it('redacts filesystem paths from auth read failures', async () => {
|
||||
vi.doMock('node:fs', () => ({
|
||||
existsSync: vi.fn(() => true),
|
||||
readFileSync: vi.fn(() => {
|
||||
throw new Error(
|
||||
'EACCES: permission denied, open /Users/brennanbenson/private/.grok/auth.json'
|
||||
)
|
||||
})
|
||||
}))
|
||||
const { readGrokAuthSession } = await import('./grok-auth')
|
||||
|
||||
expect(readGrokAuthSession()).toEqual({
|
||||
status: 'error',
|
||||
error: 'Unable to read Grok auth file'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a token-less auth file as signed out, not an error', async () => {
|
||||
vi.doMock('node:fs', () => ({
|
||||
existsSync: vi.fn(() => true),
|
||||
readFileSync: vi.fn(() => JSON.stringify({ 'https://auth.x.ai::client': { user_id: 'u1' } }))
|
||||
}))
|
||||
const { readGrokAuthSession } = await import('./grok-auth')
|
||||
|
||||
expect(readGrokAuthSession()).toEqual({ status: 'missing' })
|
||||
})
|
||||
|
||||
it('reports malformed auth JSON without parser details', async () => {
|
||||
vi.doMock('node:fs', () => ({
|
||||
existsSync: vi.fn(() => true),
|
||||
readFileSync: vi.fn(() => '{')
|
||||
}))
|
||||
const { readGrokAuthSession } = await import('./grok-auth')
|
||||
|
||||
expect(readGrokAuthSession()).toEqual({
|
||||
status: 'error',
|
||||
error: 'Grok auth file is invalid'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why: when GROK_HOME is set, auth.json must be the same path Grok CLI uses.
|
||||
export function getGrokHome(): string {
|
||||
return process.env.GROK_HOME?.trim() || join(homedir(), '.grok')
|
||||
}
|
||||
|
||||
export function getGrokAuthPath(): string {
|
||||
return join(getGrokHome(), 'auth.json')
|
||||
}
|
||||
|
||||
export type GrokAuthSession = {
|
||||
accessToken: string
|
||||
userId: string | null
|
||||
email: string | null
|
||||
teamId: string | null
|
||||
expiresAtMs: number | null
|
||||
oidcClientId: string | null
|
||||
}
|
||||
|
||||
type GrokAuthEntry = {
|
||||
key?: string
|
||||
user_id?: string
|
||||
email?: string
|
||||
team_id?: string
|
||||
expires_at?: string
|
||||
oidc_client_id?: string
|
||||
}
|
||||
|
||||
export type GrokAuthReadResult =
|
||||
| { status: 'missing' }
|
||||
| { status: 'error'; error: string }
|
||||
| { status: 'ok'; session: GrokAuthSession }
|
||||
|
||||
function getGrokAuthReadError(err: unknown): string {
|
||||
if (err instanceof SyntaxError) {
|
||||
return 'Grok auth file is invalid'
|
||||
}
|
||||
// Why: filesystem errors often include the full auth path; renderer/mobile
|
||||
// account state should not expose local usernames or custom GROK_HOME values.
|
||||
return 'Unable to read Grok auth file'
|
||||
}
|
||||
|
||||
function parseAuthEntry(value: unknown): GrokAuthEntry | null {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return null
|
||||
}
|
||||
const entry = value as GrokAuthEntry
|
||||
if (typeof entry.key !== 'string' || entry.key.length === 0) {
|
||||
return null
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
function parseExpiresAtMs(iso: string | undefined): number | null {
|
||||
if (!iso) {
|
||||
return null
|
||||
}
|
||||
const ms = Date.parse(iso)
|
||||
return Number.isFinite(ms) ? ms : null
|
||||
}
|
||||
|
||||
export function readGrokAuthSession(): GrokAuthReadResult {
|
||||
const path = getGrokAuthPath()
|
||||
if (!existsSync(path)) {
|
||||
return { status: 'missing' }
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'))
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return { status: 'error', error: 'Grok auth file is invalid' }
|
||||
}
|
||||
for (const entry of Object.values(parsed)) {
|
||||
const authEntry = parseAuthEntry(entry)
|
||||
if (!authEntry?.key) {
|
||||
continue
|
||||
}
|
||||
return {
|
||||
status: 'ok',
|
||||
session: {
|
||||
accessToken: authEntry.key,
|
||||
userId: typeof authEntry.user_id === 'string' ? authEntry.user_id : null,
|
||||
email: typeof authEntry.email === 'string' ? authEntry.email : null,
|
||||
teamId: typeof authEntry.team_id === 'string' ? authEntry.team_id : null,
|
||||
expiresAtMs: parseExpiresAtMs(authEntry.expires_at),
|
||||
oidcClientId:
|
||||
typeof authEntry.oidc_client_id === 'string' ? authEntry.oidc_client_id : null
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: a token-less file (e.g. after grok logout) means signed out, not a
|
||||
// failure — 'error' would keep a status-bar alert visible for that user.
|
||||
return { status: 'missing' }
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: getGrokAuthReadError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasGrokAuthSession(): boolean {
|
||||
return readGrokAuthSession().status === 'ok'
|
||||
}
|
||||
|
||||
const TOKEN_SKEW_MS = 5 * 60 * 1000
|
||||
|
||||
export function isGrokAccessTokenFresh(session: GrokAuthSession): boolean {
|
||||
if (session.expiresAtMs === null) {
|
||||
// Why: auth.json may lack expiry; a bad token still surfaces as billing HTTP 401.
|
||||
return true
|
||||
}
|
||||
return session.expiresAtMs - Date.now() > TOKEN_SKEW_MS
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const netFetchMock = vi.hoisted(() => vi.fn())
|
||||
const authState = vi.hoisted<{
|
||||
file: string | null
|
||||
readError: Error | null
|
||||
}>(() => ({ file: null, readError: null }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
net: { fetch: netFetchMock }
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: () => authState.file !== null,
|
||||
readFileSync: () => {
|
||||
if (authState.readError) {
|
||||
throw authState.readError
|
||||
}
|
||||
if (authState.file === null) {
|
||||
throw new Error('ENOENT')
|
||||
}
|
||||
return authState.file
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:os', () => ({ homedir: () => '/home/test' }))
|
||||
|
||||
import { fetchGrokRateLimits } from './grok-fetcher'
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body
|
||||
} as Response
|
||||
}
|
||||
|
||||
const BILLING_RESPONSE = {
|
||||
config: {
|
||||
creditUsagePercent: 42,
|
||||
currentPeriod: {
|
||||
type: 'USAGE_PERIOD_TYPE_WEEKLY',
|
||||
start: '2026-06-30T18:36:14.268512+00:00',
|
||||
end: '2026-07-07T18:36:14.268512+00:00'
|
||||
},
|
||||
subscriptionTier: 'SuperGrok',
|
||||
isUnifiedBillingUser: true
|
||||
}
|
||||
}
|
||||
|
||||
function freshAuthJson(): string {
|
||||
return JSON.stringify({
|
||||
'https://auth.x.ai::client': {
|
||||
key: 'access-token',
|
||||
user_id: 'user-1',
|
||||
email: 'dev@example.com',
|
||||
expires_at: '2099-01-01T00:00:00.000Z'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('fetchGrokRateLimits', () => {
|
||||
beforeEach(() => {
|
||||
netFetchMock.mockReset()
|
||||
authState.file = null
|
||||
authState.readError = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('returns unavailable when not signed in', async () => {
|
||||
const result = await fetchGrokRateLimits()
|
||||
expect(result.provider).toBe('grok')
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps weekly credit usage from billing config', async () => {
|
||||
authState.file = freshAuthJson()
|
||||
netFetchMock.mockResolvedValueOnce(jsonResponse(BILLING_RESPONSE))
|
||||
|
||||
const result = await fetchGrokRateLimits()
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.weekly?.usedPercent).toBe(42)
|
||||
expect(result.weekly?.windowMinutes).toBe(10_080)
|
||||
expect(result.usageMetadata?.source).toBe('oauth')
|
||||
expect(result.usageMetadata?.authProvenance).toContain('SuperGrok')
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
'https://cli-chat-proxy.grok.com/v1/billing?format=credits',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer access-token',
|
||||
'X-XAI-Token-Auth': 'xai-grok-cli',
|
||||
'x-userid': 'user-1'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('returns unavailable when not signed in even if a token-less auth file exists', async () => {
|
||||
authState.file = JSON.stringify({})
|
||||
const result = await fetchGrokRateLimits()
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns unavailable when billing has no credit usage', async () => {
|
||||
authState.file = freshAuthJson()
|
||||
netFetchMock.mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } }))
|
||||
|
||||
const result = await fetchGrokRateLimits()
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.weekly).toBeNull()
|
||||
})
|
||||
|
||||
it('returns unavailable when billing response has no config', async () => {
|
||||
authState.file = freshAuthJson()
|
||||
netFetchMock.mockResolvedValueOnce(jsonResponse({}))
|
||||
|
||||
const result = await fetchGrokRateLimits()
|
||||
expect(result.status).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('aborts the billing request when the caller aborts', async () => {
|
||||
authState.file = freshAuthJson()
|
||||
const controller = new AbortController()
|
||||
let requestSignal: AbortSignal | undefined
|
||||
netFetchMock.mockImplementationOnce((_url, init: RequestInit) => {
|
||||
requestSignal = init.signal as AbortSignal
|
||||
return new Promise((_resolve, reject) => {
|
||||
requestSignal?.addEventListener('abort', () => reject(new Error('aborted')), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const resultPromise = fetchGrokRateLimits({ signal: controller.signal })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(requestSignal?.aborted).toBe(false)
|
||||
controller.abort()
|
||||
expect(requestSignal?.aborted).toBe(true)
|
||||
|
||||
const result = await resultPromise
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toBe('aborted')
|
||||
})
|
||||
|
||||
it('returns error when the session token is expired', async () => {
|
||||
authState.file = JSON.stringify({
|
||||
'https://auth.x.ai::client': {
|
||||
key: 'stale',
|
||||
expires_at: '2000-01-01T00:00:00.000Z'
|
||||
}
|
||||
})
|
||||
const result = await fetchGrokRateLimits()
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toMatch(/expired/i)
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { net } from 'electron'
|
||||
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
|
||||
import {
|
||||
isGrokAccessTokenFresh,
|
||||
readGrokAuthSession,
|
||||
type GrokAuthReadResult,
|
||||
type GrokAuthSession
|
||||
} from './grok-auth'
|
||||
|
||||
// Why: billing URL and headers must match Grok CLI or xAI rejects the request.
|
||||
const GROK_CLI_PROXY_BASE =
|
||||
process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim().replace(/\/$/, '') ||
|
||||
'https://cli-chat-proxy.grok.com/v1'
|
||||
const BILLING_CREDITS_URL = `${GROK_CLI_PROXY_BASE}/billing?format=credits`
|
||||
const API_TIMEOUT_MS = 10_000
|
||||
const WEEKLY_WINDOW_MINUTES = 10_080
|
||||
|
||||
const GROK_CLI_AUTH_HEADER = 'xai-grok-cli'
|
||||
|
||||
type GrokMoneyVal = { val?: string | number }
|
||||
|
||||
type GrokUsagePeriod = {
|
||||
type?: string
|
||||
start?: string
|
||||
end?: string
|
||||
}
|
||||
|
||||
type GrokBillingConfig = {
|
||||
creditUsagePercent?: number
|
||||
currentPeriod?: GrokUsagePeriod
|
||||
billingPeriodStart?: string
|
||||
billingPeriodEnd?: string
|
||||
subscriptionTier?: string
|
||||
onDemandCap?: GrokMoneyVal
|
||||
onDemandUsed?: GrokMoneyVal
|
||||
prepaidBalance?: GrokMoneyVal
|
||||
isUnifiedBillingUser?: boolean
|
||||
}
|
||||
|
||||
type GrokBillingResponse = GrokBillingConfig & {
|
||||
config?: GrokBillingConfig
|
||||
}
|
||||
|
||||
function result(status: ProviderRateLimits['status'], error: string | null): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'grok',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error,
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
function parseResetDescription(isoString: string | undefined): string | null {
|
||||
if (!isoString) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(isoString)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
const isToday = date.toDateString() === new Date().toDateString()
|
||||
return isToday
|
||||
? date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||
: date.toLocaleDateString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function mapWeeklyCredits(config: GrokBillingConfig): RateLimitWindow | null {
|
||||
const usedPercent = config.creditUsagePercent
|
||||
if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) {
|
||||
return null
|
||||
}
|
||||
const periodEnd = config.currentPeriod?.end ?? config.billingPeriodEnd
|
||||
const resetsAt = periodEnd ? Date.parse(periodEnd) : null
|
||||
return {
|
||||
usedPercent: Math.min(100, Math.max(0, usedPercent)),
|
||||
windowMinutes: WEEKLY_WINDOW_MINUTES,
|
||||
resetsAt: resetsAt !== null && Number.isFinite(resetsAt) ? resetsAt : null,
|
||||
resetDescription: parseResetDescription(periodEnd)
|
||||
}
|
||||
}
|
||||
|
||||
function grokRequestHeaders(session: GrokAuthSession): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
'X-XAI-Token-Auth': GROK_CLI_AUTH_HEADER,
|
||||
Accept: 'application/json'
|
||||
}
|
||||
if (session.userId) {
|
||||
headers['x-userid'] = session.userId
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
function resolveBillingConfig(data: GrokBillingResponse): GrokBillingConfig | null {
|
||||
if (data.config) {
|
||||
return data.config
|
||||
}
|
||||
if (typeof data.creditUsagePercent === 'number') {
|
||||
return data
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mapBillingResponse(
|
||||
data: GrokBillingResponse,
|
||||
session: GrokAuthSession
|
||||
): ProviderRateLimits {
|
||||
const config = resolveBillingConfig(data)
|
||||
// Why: a 200 without credit usage means the plan has no weekly credits —
|
||||
// 'unavailable' hides the bar (like Claude on API-key billing); 'error'
|
||||
// would paint a permanent alert for a signed-in account that has no quota.
|
||||
if (!config) {
|
||||
return result('unavailable', 'Grok billing response did not include config')
|
||||
}
|
||||
const weekly = mapWeeklyCredits(config)
|
||||
const tier = config.subscriptionTier?.trim()
|
||||
const authLabel = session.email?.trim() || session.userId || 'Grok account'
|
||||
const provenance = tier ? `${authLabel} (${tier})` : authLabel
|
||||
return {
|
||||
provider: 'grok',
|
||||
session: null,
|
||||
weekly,
|
||||
updatedAt: Date.now(),
|
||||
error: weekly ? null : 'Grok billing response did not include credit usage',
|
||||
status: weekly ? 'ok' : 'unavailable',
|
||||
usageMetadata: {
|
||||
source: 'oauth',
|
||||
authProvenance: provenance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: Orca never runs grok login; it only reads the session file the CLI updates.
|
||||
export async function fetchGrokRateLimits(
|
||||
options: { signal?: AbortSignal; authReadResult?: GrokAuthReadResult } = {}
|
||||
): Promise<ProviderRateLimits> {
|
||||
const readResult = options.authReadResult ?? readGrokAuthSession()
|
||||
if (readResult.status === 'missing') {
|
||||
return result('unavailable', 'Not signed in to Grok — run grok login')
|
||||
}
|
||||
if (readResult.status === 'error') {
|
||||
return result('error', readResult.error)
|
||||
}
|
||||
const session = readResult.session
|
||||
if (!isGrokAccessTokenFresh(session)) {
|
||||
return result('error', 'Grok session expired — run grok login to refresh')
|
||||
}
|
||||
|
||||
try {
|
||||
const signal = options.signal
|
||||
? AbortSignal.any([options.signal, AbortSignal.timeout(API_TIMEOUT_MS)])
|
||||
: AbortSignal.timeout(API_TIMEOUT_MS)
|
||||
const res = await net.fetch(BILLING_CREDITS_URL, {
|
||||
headers: grokRequestHeaders(session),
|
||||
signal
|
||||
})
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return result('error', `Grok usage request unauthorized (HTTP ${res.status})`)
|
||||
}
|
||||
if (!res.ok) {
|
||||
return result('error', `Grok usage request failed (HTTP ${res.status})`)
|
||||
}
|
||||
const data: unknown = await res.json()
|
||||
return mapBillingResponse(
|
||||
typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {},
|
||||
session
|
||||
)
|
||||
} catch (err) {
|
||||
return result('error', err instanceof Error ? err.message : 'Grok usage request failed')
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,10 @@ import { RateLimitService } from './service'
|
||||
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
|
||||
import { fetchKimiRateLimits } from './kimi-fetcher'
|
||||
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
|
||||
import { fetchGrokRateLimits } from './grok-fetcher'
|
||||
import { readGrokAuthSession } from './grok-auth'
|
||||
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
|
||||
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
|
||||
|
||||
@@ -30,10 +33,22 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
|
||||
fetchOpenCodeGoRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./kimi-fetcher', () => ({
|
||||
fetchKimiRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax-fetcher', () => ({
|
||||
fetchMiniMaxRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./grok-fetcher', () => ({
|
||||
fetchGrokRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./grok-auth', () => ({
|
||||
readGrokAuthSession: vi.fn(() => ({ status: 'missing' }))
|
||||
}))
|
||||
|
||||
vi.mock('../minimax/minimax-cookie-store', () => ({
|
||||
hasMiniMaxSessionCookie: vi.fn(() => false)
|
||||
}))
|
||||
@@ -51,8 +66,14 @@ function deferred<T>(): Deferred<T> {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function flushMicrotasks(times = 4): Promise<void> {
|
||||
for (let i = 0; i < times; i += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function okProvider(
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok',
|
||||
usedPercent: number,
|
||||
updatedAt = Date.now()
|
||||
): ProviderRateLimits {
|
||||
@@ -72,7 +93,7 @@ function okProvider(
|
||||
}
|
||||
|
||||
function errorProvider(
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok',
|
||||
message: string
|
||||
): ProviderRateLimits {
|
||||
return {
|
||||
@@ -126,8 +147,72 @@ describe('RateLimitService', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now()))
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now()))
|
||||
vi.mocked(fetchKimiRateLimits).mockResolvedValue(okProvider('kimi', 0, Date.now()))
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now()))
|
||||
vi.mocked(fetchGrokRateLimits).mockResolvedValue({
|
||||
provider: 'grok',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'unavailable'
|
||||
})
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false)
|
||||
vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' })
|
||||
})
|
||||
|
||||
it('does not reread Grok auth when callers read state snapshots', () => {
|
||||
vi.mocked(readGrokAuthSession).mockReturnValue({
|
||||
status: 'ok',
|
||||
session: {
|
||||
accessToken: 'token',
|
||||
userId: null,
|
||||
email: null,
|
||||
teamId: null,
|
||||
expiresAtMs: null,
|
||||
oidcClientId: null
|
||||
}
|
||||
})
|
||||
const service = new RateLimitService()
|
||||
vi.mocked(readGrokAuthSession).mockClear()
|
||||
|
||||
expect(service.getState().grokAuthConfigured).toBe(true)
|
||||
service.getState()
|
||||
|
||||
expect(readGrokAuthSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes Grok without refreshing other providers', async () => {
|
||||
const authReadResult = {
|
||||
status: 'ok' as const,
|
||||
session: {
|
||||
accessToken: 'token',
|
||||
userId: null,
|
||||
email: 'dev@example.com',
|
||||
teamId: null,
|
||||
expiresAtMs: null,
|
||||
oidcClientId: null
|
||||
}
|
||||
}
|
||||
vi.mocked(readGrokAuthSession).mockReturnValue(authReadResult)
|
||||
vi.mocked(fetchGrokRateLimits).mockResolvedValueOnce(okProvider('grok', 42))
|
||||
const service = new RateLimitService()
|
||||
|
||||
await service.refreshGrok()
|
||||
|
||||
expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGrokRateLimits).toHaveBeenCalledWith({
|
||||
authReadResult,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(fetchClaudeRateLimits).not.toHaveBeenCalled()
|
||||
expect(fetchCodexRateLimits).not.toHaveBeenCalled()
|
||||
expect(fetchGeminiRateLimits).not.toHaveBeenCalled()
|
||||
expect(fetchOpenCodeGoRateLimits).not.toHaveBeenCalled()
|
||||
expect(fetchKimiRateLimits).not.toHaveBeenCalled()
|
||||
expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled()
|
||||
expect(service.getState().grokAuthConfigured).toBe(true)
|
||||
expect(service.getState().grok?.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => {
|
||||
@@ -345,9 +430,47 @@ describe('RateLimitService', () => {
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('publishes non-Grok provider results before a slow Grok fetch completes', async () => {
|
||||
const service = new RateLimitService()
|
||||
const grok = deferred<ProviderRateLimits>()
|
||||
let refreshResolved = false
|
||||
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now()))
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce(
|
||||
okProvider('opencode-go', 40, Date.now())
|
||||
)
|
||||
vi.mocked(fetchKimiRateLimits).mockResolvedValueOnce(okProvider('kimi', 50, Date.now()))
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValueOnce(okProvider('minimax', 60, Date.now()))
|
||||
vi.mocked(fetchGrokRateLimits).mockReturnValueOnce(grok.promise)
|
||||
|
||||
const refresh = service.refresh().then(() => {
|
||||
refreshResolved = true
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
const pendingGrokState = service.getState()
|
||||
expect(pendingGrokState.claude?.status).toBe('ok')
|
||||
expect(pendingGrokState.codex?.status).toBe('ok')
|
||||
expect(pendingGrokState.gemini?.status).toBe('ok')
|
||||
expect(pendingGrokState.opencodeGo?.status).toBe('ok')
|
||||
expect(pendingGrokState.kimi?.status).toBe('ok')
|
||||
expect(pendingGrokState.minimax?.status).toBe('ok')
|
||||
expect(pendingGrokState.grok?.status).toBe('fetching')
|
||||
expect(refreshResolved).toBe(false)
|
||||
|
||||
grok.resolve(okProvider('grok', 70, Date.now()))
|
||||
await refresh
|
||||
|
||||
const completedState = service.getState()
|
||||
expect(completedState.grok?.status).toBe('ok')
|
||||
expect(refreshResolved).toBe(true)
|
||||
})
|
||||
|
||||
it('aborts the active fetch cycle and clears queued refreshes on stop', async () => {
|
||||
const service = new RateLimitService()
|
||||
const capturedSignals: { claude?: AbortSignal; codex?: AbortSignal } = {}
|
||||
const capturedSignals: { claude?: AbortSignal; codex?: AbortSignal; grok?: AbortSignal } = {}
|
||||
|
||||
vi.mocked(fetchClaudeRateLimits).mockImplementation(
|
||||
(options) =>
|
||||
@@ -371,6 +494,17 @@ describe('RateLimitService', () => {
|
||||
)
|
||||
})
|
||||
)
|
||||
vi.mocked(fetchGrokRateLimits).mockImplementation(
|
||||
(options) =>
|
||||
new Promise((resolve) => {
|
||||
capturedSignals.grok = options?.signal
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => resolve(errorProvider('grok', 'aborted')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const activeFetch = serviceInternals(service).fetchAll()
|
||||
await Promise.resolve()
|
||||
@@ -383,12 +517,14 @@ describe('RateLimitService', () => {
|
||||
|
||||
expect(capturedSignals.claude?.aborted).toBe(true)
|
||||
expect(capturedSignals.codex?.aborted).toBe(true)
|
||||
expect(capturedSignals.grok?.aborted).toBe(true)
|
||||
|
||||
await queuedRefresh
|
||||
await activeFetch
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('aborts inactive Claude preview fetches on stop', async () => {
|
||||
@@ -480,6 +616,10 @@ describe('RateLimitService', () => {
|
||||
expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true)
|
||||
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledWith('session=abc123', undefined)
|
||||
expect(fetchGrokRateLimits).toHaveBeenCalledWith({
|
||||
signal: expect.any(AbortSignal),
|
||||
authReadResult: { status: 'missing' }
|
||||
})
|
||||
|
||||
const state = service.getState()
|
||||
expect(state.claude?.status).toBe('ok')
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
} from '../claude-accounts/runtime-selection'
|
||||
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
|
||||
import { fetchKimiRateLimits } from './kimi-fetcher'
|
||||
import { fetchGrokRateLimits } from './grok-fetcher'
|
||||
import { readGrokAuthSession } from './grok-auth'
|
||||
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
|
||||
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
|
||||
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
|
||||
@@ -78,6 +80,7 @@ type InternalRateLimitState = {
|
||||
opencodeGo: ProviderRateLimits | null
|
||||
kimi: ProviderRateLimits | null
|
||||
minimax: ProviderRateLimits | null
|
||||
grok: ProviderRateLimits | null
|
||||
}
|
||||
|
||||
function normalizePollingInterval(ms: number): number {
|
||||
@@ -110,8 +113,10 @@ export class RateLimitService {
|
||||
gemini: null,
|
||||
opencodeGo: null,
|
||||
kimi: null,
|
||||
minimax: null
|
||||
minimax: null,
|
||||
grok: null
|
||||
}
|
||||
private grokAuthConfigured = readGrokAuthSession().status === 'ok'
|
||||
private pollInterval: number = DEFAULT_POLL_MS
|
||||
private timer: ReturnType<typeof setInterval> | null = null
|
||||
private deferredStartupRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -122,6 +127,7 @@ export class RateLimitService {
|
||||
private fullFetchQueued = false
|
||||
private codexOnlyFetchQueued = false
|
||||
private claudeOnlyFetchQueued = false
|
||||
private grokOnlyFetchQueued = false
|
||||
private activeFetchAbortControllers = new Set<AbortController>()
|
||||
private fetchIdleResolvers: (() => void)[] = []
|
||||
private codexFetchGeneration = 0
|
||||
@@ -269,6 +275,7 @@ export class RateLimitService {
|
||||
// its presence on the pushed state so the renderer keeps the MiniMax
|
||||
// bar visible across reloads and between snapshot refreshes.
|
||||
minimaxCookieConfigured: hasMiniMaxSessionCookie(),
|
||||
grokAuthConfigured: this.grokAuthConfigured,
|
||||
claudeTarget: this.claudeFetchTarget,
|
||||
codexTarget: this.codexFetchTarget,
|
||||
inactiveClaudeAccounts: this.buildInactiveArray(
|
||||
@@ -291,6 +298,11 @@ export class RateLimitService {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
async refreshGrok(): Promise<RateLimitState> {
|
||||
await this.fetchGrokOnly({ force: true })
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
invalidateMiniMaxCredentialState(): void {
|
||||
this.minimaxFetchGeneration += 1
|
||||
// Why: saving or forgetting the browser cookie can race an in-flight usage
|
||||
@@ -740,6 +752,15 @@ export class RateLimitService {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (this.grokOnlyFetchQueued) {
|
||||
this.grokOnlyFetchQueued = false
|
||||
const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchGrokOnlyCycle(fetchSignal)
|
||||
)
|
||||
if (grokSignal.aborted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isFetching = false
|
||||
@@ -790,6 +811,15 @@ export class RateLimitService {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (this.grokOnlyFetchQueued) {
|
||||
this.grokOnlyFetchQueued = false
|
||||
const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchGrokOnlyCycle(fetchSignal)
|
||||
)
|
||||
if (grokSignal.aborted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isFetching = false
|
||||
@@ -840,6 +870,74 @@ export class RateLimitService {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (this.grokOnlyFetchQueued) {
|
||||
this.grokOnlyFetchQueued = false
|
||||
const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchGrokOnlyCycle(fetchSignal)
|
||||
)
|
||||
if (grokSignal.aborted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isFetching = false
|
||||
this.resolveFetchIdleWaiters()
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchGrokOnly(options?: { force?: boolean }): Promise<void> {
|
||||
if (this.isFetching) {
|
||||
if (options?.force) {
|
||||
this.grokOnlyFetchQueued = true
|
||||
return this.waitForFetchIdle()
|
||||
}
|
||||
return
|
||||
}
|
||||
this.isFetching = true
|
||||
|
||||
try {
|
||||
let shouldContinue = true
|
||||
while (shouldContinue) {
|
||||
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchGrokOnlyCycle(fetchSignal)
|
||||
)
|
||||
shouldContinue = false
|
||||
if (signal.aborted) {
|
||||
break
|
||||
}
|
||||
if (this.fullFetchQueued) {
|
||||
this.fullFetchQueued = false
|
||||
const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchAllCycle(fetchSignal)
|
||||
)
|
||||
if (fullSignal.aborted) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (this.grokOnlyFetchQueued) {
|
||||
this.grokOnlyFetchQueued = false
|
||||
shouldContinue = true
|
||||
}
|
||||
if (this.codexOnlyFetchQueued) {
|
||||
this.codexOnlyFetchQueued = false
|
||||
const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchCodexOnlyCycle(fetchSignal)
|
||||
)
|
||||
if (codexSignal.aborted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (this.claudeOnlyFetchQueued) {
|
||||
this.claudeOnlyFetchQueued = false
|
||||
const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchClaudeOnlyCycle(fetchSignal)
|
||||
)
|
||||
if (claudeSignal.aborted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isFetching = false
|
||||
@@ -852,7 +950,8 @@ export class RateLimitService {
|
||||
!this.isFetching &&
|
||||
!this.fullFetchQueued &&
|
||||
!this.codexOnlyFetchQueued &&
|
||||
!this.claudeOnlyFetchQueued
|
||||
!this.claudeOnlyFetchQueued &&
|
||||
!this.grokOnlyFetchQueued
|
||||
) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
@@ -869,7 +968,8 @@ export class RateLimitService {
|
||||
this.isFetching ||
|
||||
this.fullFetchQueued ||
|
||||
this.codexOnlyFetchQueued ||
|
||||
this.claudeOnlyFetchQueued
|
||||
this.claudeOnlyFetchQueued ||
|
||||
this.grokOnlyFetchQueued
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -913,6 +1013,7 @@ export class RateLimitService {
|
||||
this.fullFetchQueued = false
|
||||
this.codexOnlyFetchQueued = false
|
||||
this.claudeOnlyFetchQueued = false
|
||||
this.grokOnlyFetchQueued = false
|
||||
}
|
||||
|
||||
private resolveAndClearFetchIdleWaiters(): void {
|
||||
@@ -1025,7 +1126,7 @@ export class RateLimitService {
|
||||
|
||||
private withFetchingStatus(
|
||||
current: ProviderRateLimits | null,
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax'
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok'
|
||||
): ProviderRateLimits {
|
||||
if (!current) {
|
||||
return {
|
||||
@@ -1064,6 +1165,10 @@ export class RateLimitService {
|
||||
const miniMaxGroupId = miniMaxConfigResult.config.groupId
|
||||
const miniMaxModels = miniMaxConfigResult.config.models
|
||||
const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false
|
||||
// Why: getState() is used by renderer pushes and mobile snapshots; keep
|
||||
// Grok's sync auth-file probe on fetch cycles instead of every state read.
|
||||
const grokAuthReadResult = readGrokAuthSession()
|
||||
this.grokAuthConfigured = grokAuthReadResult.status === 'ok'
|
||||
|
||||
// Detect if configuration changed — if it did, we must discard any stale
|
||||
// data because it belongs to a different session/workspace.
|
||||
@@ -1097,12 +1202,21 @@ export class RateLimitService {
|
||||
kimi: this.withFetchingStatus(previousState.kimi, 'kimi'),
|
||||
minimax: miniMaxConfigChanged
|
||||
? this.withFetchingStatus(null, 'minimax')
|
||||
: this.withFetchingStatus(previousState.minimax, 'minimax')
|
||||
: this.withFetchingStatus(previousState.minimax, 'minimax'),
|
||||
grok: this.withFetchingStatus(previousState.grok, 'grok')
|
||||
})
|
||||
|
||||
const missingWslCodexHome = codexHomePath
|
||||
? null
|
||||
: this.getMissingWslCodexHomeResult(codexTarget)
|
||||
const grokResultPromise = fetchGrokRateLimits({
|
||||
signal,
|
||||
authReadResult: grokAuthReadResult
|
||||
}).then(
|
||||
(value) => ({ status: 'fulfilled', value }) as const,
|
||||
(reason) => ({ status: 'rejected', reason }) as const
|
||||
)
|
||||
|
||||
const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] =
|
||||
await Promise.allSettled([
|
||||
fetchClaudeRateLimits({
|
||||
@@ -1237,7 +1351,7 @@ export class RateLimitService {
|
||||
// generation still match, otherwise an old account could overwrite the
|
||||
// newly selected account's quota state.
|
||||
this.updateState({
|
||||
...previousState,
|
||||
...this.state,
|
||||
claude: shouldApplyClaude
|
||||
? this.applyStalePolicy(claude, previousState.claude)
|
||||
: this.state.claude,
|
||||
@@ -1258,6 +1372,26 @@ export class RateLimitService {
|
||||
: this.state.minimax
|
||||
})
|
||||
|
||||
const grokResult = await grokResultPromise
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const grok =
|
||||
grokResult.status === 'fulfilled'
|
||||
? grokResult.value
|
||||
: ({
|
||||
provider: 'grok',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error',
|
||||
status: 'error'
|
||||
} satisfies ProviderRateLimits)
|
||||
this.updateState({
|
||||
...this.state,
|
||||
grok: this.applyStalePolicy(grok, previousState.grok)
|
||||
})
|
||||
|
||||
this.lastFetchAt = Date.now()
|
||||
}
|
||||
|
||||
@@ -1374,6 +1508,45 @@ export class RateLimitService {
|
||||
this.lastFetchAt = Date.now()
|
||||
}
|
||||
|
||||
private async runFetchGrokOnlyCycle(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const previousState = this.state
|
||||
const grokAuthReadResult = readGrokAuthSession()
|
||||
this.grokAuthConfigured = grokAuthReadResult.status === 'ok'
|
||||
|
||||
this.updateState({
|
||||
...previousState,
|
||||
grok: this.withFetchingStatus(previousState.grok, 'grok')
|
||||
})
|
||||
|
||||
const grok = await fetchGrokRateLimits({
|
||||
signal,
|
||||
authReadResult: grokAuthReadResult
|
||||
}).catch(
|
||||
(err): ProviderRateLimits => ({
|
||||
provider: 'grok',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
status: 'error'
|
||||
})
|
||||
)
|
||||
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
this.updateState({
|
||||
...this.state,
|
||||
grok: this.applyStalePolicy(grok, previousState.grok)
|
||||
})
|
||||
|
||||
this.lastFetchAt = Date.now()
|
||||
}
|
||||
|
||||
private applyStalePolicy(
|
||||
fresh: ProviderRateLimits,
|
||||
previous: ProviderRateLimits | null
|
||||
|
||||
@@ -5459,7 +5459,7 @@ describe('OrcaRuntimeService', () => {
|
||||
it('keeps project clone setup on the cloned host-qualified repo', async () => {
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
|
||||
const clonePath = join(destination, 'orca')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const repos: Record<string, unknown>[] = []
|
||||
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
const runtimeStore = {
|
||||
@@ -5521,7 +5521,7 @@ describe('OrcaRuntimeService', () => {
|
||||
it('adopts public clone repos into host-qualified project setup', async () => {
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
|
||||
const clonePath = join(destination, 'orca')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const repos: Record<string, unknown>[] = []
|
||||
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
const runtimeStore = {
|
||||
@@ -5591,7 +5591,7 @@ describe('OrcaRuntimeService', () => {
|
||||
it('keeps project clone repos split by runtime host on the same clone path', async () => {
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
|
||||
const clonePath = join(destination, 'orca')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const repos: Record<string, unknown>[] = [
|
||||
{
|
||||
id: 'repo-host-a',
|
||||
@@ -5762,7 +5762,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('defaults runtime cloneRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const added: Record<string, unknown>[] = []
|
||||
const colorStore = {
|
||||
...store,
|
||||
@@ -5798,7 +5798,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('preserves existing badgeColor on runtime cloneRepo folder->git dedupe upgrade', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
spawnSpy.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
@@ -5896,7 +5896,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('rejects runtime cloneRepo dot-segment URLs before spawning git', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const runtime = createRuntime()
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
|
||||
const destination = join(tempRoot, 'destination')
|
||||
@@ -5913,7 +5913,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('rejects runtime cloneRepo parent-segment URLs before spawning git', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const runtime = createRuntime()
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
|
||||
const destination = join(tempRoot, 'destination')
|
||||
@@ -5930,7 +5930,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('removes an owned runtime clone target when git exits unsuccessfully', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
spawnSpy.mockReturnValue(proc as never)
|
||||
@@ -5957,7 +5957,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('preserves an existing runtime clone target when git exits unsuccessfully', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
spawnSpy.mockReturnValue(proc as never)
|
||||
@@ -5984,7 +5984,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('skips runtime clone failure cleanup when the owned target is replaced', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
spawnSpy.mockReturnValue(proc as never)
|
||||
@@ -6013,7 +6013,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('serializes concurrent runtime clones for the same target', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const firstProc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
firstProc.stderr = new EventEmitter()
|
||||
spawnSpy.mockReturnValueOnce(firstProc as never)
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
AGENT_PROMPT_SUBMIT_DELAY_MS,
|
||||
buildAgentPromptPasteBytes
|
||||
} from '../../shared/agent-prompt-injection'
|
||||
import { gitExecFileAsync, wslAwareSpawn } from '../git/runner'
|
||||
import { gitExecFileAsync, gitSpawn } from '../git/runner'
|
||||
import {
|
||||
cleanupClaimedCloneTarget,
|
||||
claimCloneTarget,
|
||||
@@ -6860,11 +6860,11 @@ export class OrcaRuntimeService {
|
||||
// `rateLimits:update` IPC channel desktop already uses.
|
||||
onAccountsChanged(listener: (snapshot: AccountsSnapshot) => void): () => void {
|
||||
const services = this.requireAccountServices()
|
||||
return services.rateLimits.onStateChange(() => {
|
||||
return services.rateLimits.onStateChange((rateLimits) => {
|
||||
listener({
|
||||
claude: services.claudeAccounts.listAccounts(),
|
||||
codex: services.codexAccounts.listAccounts(),
|
||||
rateLimits: services.rateLimits.getState()
|
||||
rateLimits
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -10693,9 +10693,9 @@ export class OrcaRuntimeService {
|
||||
await mkdir(trimmedDestination, { recursive: true })
|
||||
const claimedTarget = await claimCloneTarget(clonePath)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let proc: ReturnType<typeof wslAwareSpawn>
|
||||
let proc: ReturnType<typeof gitSpawn>
|
||||
try {
|
||||
proc = wslAwareSpawn('git', ['clone', '--progress', '--', trimmedUrl, clonePath], {
|
||||
proc = gitSpawn(['clone', '--progress', '--', trimmedUrl, clonePath], {
|
||||
cwd: trimmedDestination,
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
|
||||
@@ -182,10 +182,11 @@ describe('client UI RPC methods', () => {
|
||||
...getDefaultUIState(),
|
||||
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
|
||||
_worktreeCardModeDefaulted: true,
|
||||
statusBarItems: ['codex', 'kimi', 'minimax', 'ports'],
|
||||
statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'ports'],
|
||||
_portsStatusBarDefaultAdded: true,
|
||||
_kimiStatusBarDefaultAdded: true,
|
||||
_minimaxStatusBarDefaultAdded: true,
|
||||
_grokStatusBarDefaultAdded: true,
|
||||
taskResumeState: {
|
||||
githubMode: 'items',
|
||||
githubItemsQuery: 'is:open',
|
||||
@@ -221,10 +222,11 @@ describe('client UI RPC methods', () => {
|
||||
const payload = {
|
||||
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
|
||||
_worktreeCardModeDefaulted: true,
|
||||
statusBarItems: ['codex', 'kimi', 'minimax', 'ports'],
|
||||
statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'ports'],
|
||||
_portsStatusBarDefaultAdded: true,
|
||||
_kimiStatusBarDefaultAdded: true,
|
||||
_minimaxStatusBarDefaultAdded: true,
|
||||
_grokStatusBarDefaultAdded: true,
|
||||
taskResumeState: {
|
||||
githubMode: 'items',
|
||||
githubItemsQuery: 'is:open',
|
||||
|
||||
@@ -47,6 +47,7 @@ const StatusBarItem = z.enum([
|
||||
'opencode-go',
|
||||
'kimi',
|
||||
'minimax',
|
||||
'grok',
|
||||
'ssh',
|
||||
'resource-usage',
|
||||
'ports'
|
||||
@@ -203,6 +204,7 @@ const UiUpdate = z
|
||||
_portsStatusBarDefaultAdded: z.boolean().optional(),
|
||||
_kimiStatusBarDefaultAdded: z.boolean().optional(),
|
||||
_minimaxStatusBarDefaultAdded: z.boolean().optional(),
|
||||
_grokStatusBarDefaultAdded: z.boolean().optional(),
|
||||
statusBarVisible: z.boolean().optional(),
|
||||
dismissedUpdateVersion: NullableString.optional(),
|
||||
lastUpdateCheckAt: z.number().finite().nullable().optional(),
|
||||
|
||||
@@ -17,6 +17,8 @@ const {
|
||||
terminated = false
|
||||
emitStoppedOnStop = true
|
||||
emitReadyOnInit = HoistedMockWorker.emitReadyOnInit
|
||||
emitExitOnTerminate = true
|
||||
terminatePromise: Promise<void> | null = null
|
||||
messages: WorkerMessage[] = []
|
||||
private listeners = new Map<string, Set<(...args: unknown[]) => void>>()
|
||||
|
||||
@@ -68,8 +70,10 @@ const {
|
||||
|
||||
terminate(): Promise<void> {
|
||||
this.terminated = true
|
||||
this.emit('exit', 0)
|
||||
return Promise.resolve()
|
||||
if (this.emitExitOnTerminate) {
|
||||
this.emit('exit', 0)
|
||||
}
|
||||
return this.terminatePromise ?? Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,6 +481,95 @@ describe('SttService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('settles stop when the worker exits during stop and does not reuse it', async () => {
|
||||
const service = new SttService({
|
||||
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),
|
||||
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
|
||||
} as never)
|
||||
const events: unknown[] = []
|
||||
|
||||
await service.startDictation('model-a', (event) => events.push(event), undefined, 'desktop')
|
||||
const firstWorker = getLastWorker()
|
||||
expect(firstWorker).toBeDefined()
|
||||
firstWorker!.emitStoppedOnStop = false
|
||||
|
||||
const stopPromise = service.stopDictation('desktop')
|
||||
firstWorker!.emit('exit', 1)
|
||||
await stopPromise
|
||||
|
||||
expect(events).toContainEqual({ type: 'stopped' })
|
||||
|
||||
await service.startDictation('model-a', vi.fn(), undefined, 'desktop')
|
||||
|
||||
expect(getCreatedWorkerCount()).toBe(2)
|
||||
expect(getLastWorker()).not.toBe(firstWorker)
|
||||
})
|
||||
|
||||
it('settles stop when the worker errors during stop and shares the outcome with follow-up stop', async () => {
|
||||
const service = new SttService({
|
||||
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),
|
||||
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
|
||||
} as never)
|
||||
const events: unknown[] = []
|
||||
|
||||
await service.startDictation(
|
||||
'model-a',
|
||||
(event) => {
|
||||
events.push(event)
|
||||
if (event.type === 'error') {
|
||||
void service.stopDictation('desktop')
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
'desktop'
|
||||
)
|
||||
const worker = getLastWorker()
|
||||
expect(worker).toBeDefined()
|
||||
worker!.emitStoppedOnStop = false
|
||||
|
||||
const stopPromise = service.stopDictation('desktop')
|
||||
worker!.emit('error', new Error('native failure'))
|
||||
await stopPromise
|
||||
|
||||
expect(worker!.messages.filter((message) => message.type === 'stop')).toHaveLength(1)
|
||||
expect(events).toContainEqual({ type: 'error', error: 'Error: native failure' })
|
||||
expect(events).toContainEqual({ type: 'stopped' })
|
||||
})
|
||||
|
||||
it('shares one stop message across concurrent stop callers', async () => {
|
||||
const service = new SttService({
|
||||
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),
|
||||
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
|
||||
} as never)
|
||||
|
||||
await service.startDictation('model-a', vi.fn(), undefined, 'desktop')
|
||||
const worker = getLastWorker()
|
||||
expect(worker).toBeDefined()
|
||||
worker!.emitStoppedOnStop = false
|
||||
|
||||
const firstStop = service.stopDictation('desktop')
|
||||
const secondStop = service.stopDictation('desktop')
|
||||
expect(worker!.messages.filter((message) => message.type === 'stop')).toHaveLength(1)
|
||||
|
||||
worker!.emit('message', { type: 'stopped' })
|
||||
await Promise.all([firstStop, secondStop])
|
||||
})
|
||||
|
||||
it('does not emit synthetic stopped when the worker reports stopped', async () => {
|
||||
const service = new SttService({
|
||||
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),
|
||||
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
|
||||
} as never)
|
||||
const events: unknown[] = []
|
||||
|
||||
await service.startDictation('model-a', (event) => events.push(event), undefined, 'desktop')
|
||||
await service.stopDictation('desktop')
|
||||
|
||||
expect(events.filter((event) => (event as { type?: string }).type === 'stopped')).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it('does not retain or reuse a worker that timed out while stopping', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -505,4 +598,59 @@ describe('SttService', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not wait for worker termination to resolve a timed-out stop', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const service = new SttService({
|
||||
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),
|
||||
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
|
||||
} as never)
|
||||
|
||||
await service.startDictation('model-a', vi.fn(), undefined, 'desktop')
|
||||
const worker = getLastWorker()
|
||||
expect(worker).toBeDefined()
|
||||
worker!.emitStoppedOnStop = false
|
||||
worker!.emitExitOnTerminate = false
|
||||
worker!.terminatePromise = new Promise(() => {})
|
||||
|
||||
const stopPromise = service.stopDictation('desktop').then(() => 'resolved')
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
const outcome = await Promise.race([stopPromise, Promise.resolve('pending')])
|
||||
|
||||
expect(outcome).toBe('resolved')
|
||||
expect(worker!.terminated).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects idle warm reuse when the model is no longer ready', async () => {
|
||||
let modelStatus = 'ready'
|
||||
const getModelState = vi.fn().mockImplementation(async () => ({
|
||||
id: 'model-a',
|
||||
status: modelStatus
|
||||
}))
|
||||
const service = new SttService({
|
||||
getModelState,
|
||||
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
|
||||
} as never)
|
||||
|
||||
await service.startDictation('model-a', vi.fn(), undefined, 'desktop')
|
||||
const firstWorker = getLastWorker()
|
||||
expect(firstWorker).toBeDefined()
|
||||
await service.stopDictation('desktop')
|
||||
|
||||
modelStatus = 'missing'
|
||||
await expect(service.startDictation('model-a', vi.fn(), undefined, 'desktop')).rejects.toThrow(
|
||||
'Model not ready: missing'
|
||||
)
|
||||
expect(firstWorker!.terminated).toBe(true)
|
||||
|
||||
modelStatus = 'ready'
|
||||
await service.startDictation('model-a', vi.fn(), undefined, 'desktop')
|
||||
|
||||
expect(getCreatedWorkerCount()).toBe(2)
|
||||
expect(getLastWorker()).not.toBe(firstWorker)
|
||||
})
|
||||
})
|
||||
|
||||
+153
-66
@@ -22,6 +22,14 @@ export type SttEvent =
|
||||
|
||||
export type SttEventSink = (event: SttEvent) => void
|
||||
|
||||
type StopInFlight = {
|
||||
worker: Worker
|
||||
owner: string
|
||||
promise: Promise<void>
|
||||
}
|
||||
|
||||
type StopOutcome = 'stopped' | 'error' | 'exit' | 'timeout'
|
||||
|
||||
export class SttService {
|
||||
private worker: Worker | null = null
|
||||
private cloudSession: OpenAiTranscriptionSession | null = null
|
||||
@@ -35,6 +43,7 @@ export class SttService {
|
||||
private canceledOwners = new Set<string>()
|
||||
private eventSink: SttEventSink | null = null
|
||||
private idleTeardownTimer: NodeJS.Timeout | null = null
|
||||
private stopInFlight: StopInFlight | null = null
|
||||
// Why: stop resolves only after the worker flushes; in-flight feedAudio IPC
|
||||
// must not enqueue samples after that flush or they stick on the warm worker
|
||||
// and contaminate the next dictation session.
|
||||
@@ -95,8 +104,9 @@ export class SttService {
|
||||
|
||||
if (manifest.provider === 'openai') {
|
||||
if (this.worker) {
|
||||
const existingWorker = this.worker
|
||||
await this.stopDictation(owner, { cancelStarting: false })
|
||||
await this.teardownIdleWorker()
|
||||
await this.teardownWorker(existingWorker)
|
||||
}
|
||||
|
||||
const modelState = await this.modelManager.getModelState(modelId)
|
||||
@@ -116,19 +126,37 @@ export class SttService {
|
||||
await this.stopDictation(owner, { cancelStarting: false })
|
||||
}
|
||||
|
||||
const reusableWorker = this.worker
|
||||
if (
|
||||
this.worker &&
|
||||
reusableWorker &&
|
||||
this.activeModelId === modelId &&
|
||||
this.activeHotwordsFilePath === hotwordsFilePath
|
||||
this.activeHotwordsFilePath === hotwordsFilePath &&
|
||||
this.stopInFlight?.worker !== reusableWorker
|
||||
) {
|
||||
this.eventSink = sink
|
||||
sink({ type: 'ready' })
|
||||
return
|
||||
const worker = reusableWorker
|
||||
if (!this.activeOwner) {
|
||||
const modelState = await this.modelManager.getModelState(modelId)
|
||||
if (modelState.status !== 'ready') {
|
||||
await this.teardownWorker(worker)
|
||||
throw new Error(`Model not ready: ${modelState.status}`)
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.worker === worker &&
|
||||
this.activeModelId === modelId &&
|
||||
this.activeHotwordsFilePath === hotwordsFilePath &&
|
||||
this.stopInFlight?.worker !== worker
|
||||
) {
|
||||
this.eventSink = sink
|
||||
sink({ type: 'ready' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (this.worker) {
|
||||
const existingWorker = this.worker
|
||||
await this.stopDictation(owner, { cancelStarting: false })
|
||||
await this.teardownIdleWorker()
|
||||
await this.teardownWorker(existingWorker)
|
||||
}
|
||||
|
||||
const modelState = await this.modelManager.getModelState(modelId)
|
||||
@@ -198,12 +226,14 @@ export class SttService {
|
||||
})
|
||||
|
||||
const onWorkerMessage = (msg: SttEvent) => {
|
||||
this.eventSink?.(msg)
|
||||
if (this.worker === worker) {
|
||||
this.eventSink?.(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onWorkerError = (err: Error) => {
|
||||
this.eventSink?.({ type: 'error', error: String(err) })
|
||||
if (this.worker === worker) {
|
||||
this.eventSink?.({ type: 'error', error: String(err) })
|
||||
this.cleanupActiveWorkerLifecycleListeners()
|
||||
this.worker = null
|
||||
this.activeModelId = null
|
||||
@@ -295,9 +325,9 @@ export class SttService {
|
||||
throw new Error('dictation_owner_mismatch')
|
||||
}
|
||||
|
||||
this.stopping = true
|
||||
try {
|
||||
if (this.cloudSession) {
|
||||
if (this.cloudSession) {
|
||||
this.stopping = true
|
||||
try {
|
||||
const session = this.cloudSession
|
||||
this.cloudSession = null
|
||||
try {
|
||||
@@ -317,76 +347,118 @@ export class SttService {
|
||||
this.activeOwner = null
|
||||
this.eventSink = null
|
||||
}
|
||||
return
|
||||
} finally {
|
||||
this.stopping = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const worker = this.worker
|
||||
if (!worker) {
|
||||
return
|
||||
const worker = this.worker
|
||||
if (!worker) {
|
||||
return
|
||||
}
|
||||
if (this.stopInFlight?.worker === worker) {
|
||||
if (this.stopInFlight.owner !== owner) {
|
||||
throw new Error('dictation_owner_mismatch')
|
||||
}
|
||||
return this.stopInFlight.promise
|
||||
}
|
||||
|
||||
const capturedSink = this.eventSink
|
||||
let stopPromise!: Promise<void>
|
||||
stopPromise = this.createStopPromise(worker, capturedSink).finally(() => {
|
||||
if (this.stopInFlight?.worker === worker && this.stopInFlight.promise === stopPromise) {
|
||||
this.stopInFlight = null
|
||||
}
|
||||
})
|
||||
this.stopInFlight = { worker, owner, promise: stopPromise }
|
||||
this.stopping = true
|
||||
try {
|
||||
// Why: keep the stop message inside the try so a postMessage throw still
|
||||
// clears `stopping` — otherwise feedAudio silently drops all future audio.
|
||||
worker.postMessage({ type: 'stop' })
|
||||
await stopPromise
|
||||
} finally {
|
||||
this.stopping = false
|
||||
}
|
||||
}
|
||||
|
||||
let forcedTeardown = false
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
private createStopPromise(worker: Worker, capturedSink: SttEventSink | null): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
let receivedStopped = false
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
worker.off('message', onStopped)
|
||||
const cleanup = (): void => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
worker.off('message', onStopped)
|
||||
worker.off('error', onError)
|
||||
worker.off('exit', onExit)
|
||||
}
|
||||
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
const finish = (outcome: StopOutcome): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
if (outcome === 'stopped') {
|
||||
if (this.worker === worker) {
|
||||
this.activeOwner = null
|
||||
this.eventSink = null
|
||||
this.scheduleIdleTeardown()
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const onStopped = (msg: { type: string; text?: string; error?: string }) => {
|
||||
if (msg.type === 'stopped') {
|
||||
finish()
|
||||
}
|
||||
if (!receivedStopped) {
|
||||
capturedSink?.({ type: 'stopped' })
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
forcedTeardown = true
|
||||
cleanup()
|
||||
// Why: a worker that cannot finish dictation is no longer reusable; do
|
||||
// not keep it in the warm-worker slot or retain its message listeners.
|
||||
this.cleanupActiveWorkerLifecycleListeners()
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate().finally(resolve)
|
||||
}, STOP_DICTATION_TIMEOUT_MS)
|
||||
|
||||
worker.on('message', onStopped)
|
||||
})
|
||||
|
||||
if (this.worker === worker) {
|
||||
if (forcedTeardown) {
|
||||
// Why: a worker that cannot finish dictation is no longer reusable; drop
|
||||
// its lifecycle listeners so a stale worker can't retain this service.
|
||||
this.cleanupActiveWorkerLifecycleListeners()
|
||||
worker.removeAllListeners()
|
||||
if (outcome !== 'exit') {
|
||||
void worker.terminate().catch(() => undefined)
|
||||
}
|
||||
if (this.worker === worker) {
|
||||
this.worker = null
|
||||
this.activeModelId = null
|
||||
this.activeHotwordsFilePath = undefined
|
||||
this.activeOwner = null
|
||||
this.eventSink = null
|
||||
} else {
|
||||
this.activeOwner = null
|
||||
this.eventSink = null
|
||||
this.scheduleIdleTeardown()
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onStopped = (msg: { type: string; text?: string; error?: string }) => {
|
||||
if (msg.type === 'stopped') {
|
||||
receivedStopped = true
|
||||
finish('stopped')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.stopping = false
|
||||
}
|
||||
|
||||
const onError = (): void => {
|
||||
finish('error')
|
||||
}
|
||||
|
||||
const onExit = (): void => {
|
||||
finish('exit')
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
finish('timeout')
|
||||
}, STOP_DICTATION_TIMEOUT_MS)
|
||||
timeout.unref?.()
|
||||
|
||||
worker.on('message', onStopped)
|
||||
worker.on('error', onError)
|
||||
worker.on('exit', onExit)
|
||||
})
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
@@ -441,8 +513,24 @@ export class SttService {
|
||||
if (!this.worker || this.activeOwner || this.startingOwner) {
|
||||
return
|
||||
}
|
||||
const worker = this.worker
|
||||
worker.postMessage({ type: 'teardown' })
|
||||
await this.teardownWorker(this.worker, options)
|
||||
}
|
||||
|
||||
private async teardownWorker(
|
||||
worker: Worker,
|
||||
options: { ignoreTerminateErrors?: boolean } = { ignoreTerminateErrors: true }
|
||||
): Promise<void> {
|
||||
this.clearIdleTeardownTimer()
|
||||
if (this.stopInFlight?.worker === worker) {
|
||||
await this.stopInFlight.promise
|
||||
}
|
||||
try {
|
||||
worker.postMessage({ type: 'teardown' })
|
||||
} catch {
|
||||
// The worker may already have exited on a forced stop path.
|
||||
}
|
||||
this.cleanupActiveWorkerLifecycleListeners()
|
||||
worker.removeAllListeners()
|
||||
try {
|
||||
await worker.terminate()
|
||||
} catch (error) {
|
||||
@@ -450,12 +538,11 @@ export class SttService {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
this.cleanupActiveWorkerLifecycleListeners()
|
||||
worker.removeAllListeners()
|
||||
if (this.worker === worker) {
|
||||
this.worker = null
|
||||
this.activeModelId = null
|
||||
this.activeHotwordsFilePath = undefined
|
||||
this.activeOwner = null
|
||||
this.eventSink = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why: different models name their ONNX files differently (e.g.
|
||||
// encoder.int8.onnx vs tiny-encoder.onnx vs encoder-epoch-99-avg-1.onnx).
|
||||
@@ -14,7 +15,7 @@ export function resolveFile(
|
||||
if (!match) {
|
||||
throw new Error(`No *${role}*${ext} found in model files: ${files.join(', ')}`)
|
||||
}
|
||||
return `${modelDir}/${match}`
|
||||
return join(modelDir, match)
|
||||
}
|
||||
|
||||
export function resolveTokens(files: string[], modelDir: string): string {
|
||||
@@ -22,7 +23,7 @@ export function resolveTokens(files: string[], modelDir: string): string {
|
||||
if (!match) {
|
||||
throw new Error(`No *tokens.txt found in model files: ${files.join(', ')}`)
|
||||
}
|
||||
return `${modelDir}/${match}`
|
||||
return join(modelDir, match)
|
||||
}
|
||||
|
||||
// Why: BPE models need a vocab file for hotwords token matching. The file
|
||||
@@ -32,7 +33,7 @@ function discoverBpeVocab(modelDir: string): string | undefined {
|
||||
try {
|
||||
const entries = readdirSync(modelDir)
|
||||
const vocabFile = entries.find((f) => f.endsWith('.vocab'))
|
||||
return vocabFile ? `${modelDir}/${vocabFile}` : undefined
|
||||
return vocabFile ? join(modelDir, vocabFile) : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -352,6 +352,7 @@ import type {
|
||||
} from '../shared/claude-usage-types'
|
||||
import type {
|
||||
CodexRateLimitResetResult,
|
||||
GrokAccountStatus,
|
||||
RateLimitRuntimeTarget,
|
||||
RateLimitState
|
||||
} from '../shared/rate-limit-types'
|
||||
@@ -402,7 +403,12 @@ import type {
|
||||
OpenCodeUsageSnapshot,
|
||||
OpenCodeUsageSummary
|
||||
} from '../shared/opencode-usage-types'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultListArgs,
|
||||
AiVaultListResult,
|
||||
AiVaultSubagentListArgs,
|
||||
AiVaultSubagentListResult
|
||||
} from '../shared/ai-vault-types'
|
||||
import type { AgentType, NativeChatMessage } from '../shared/native-chat-types'
|
||||
import type { TelemetryConsentState } from '../shared/telemetry-consent-types'
|
||||
import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events'
|
||||
@@ -780,6 +786,8 @@ export type OpenCodeUsageApi = {
|
||||
|
||||
export type AiVaultApi = {
|
||||
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
|
||||
/** Lists the Task subagent transcripts of one session, on demand. */
|
||||
listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise<AiVaultSubagentListResult>
|
||||
/** Fires when any app window regains OS focus; returns an unsubscribe. */
|
||||
onWindowFocused: (callback: () => void) => () => void
|
||||
}
|
||||
@@ -2876,6 +2884,7 @@ export type PreloadApi = {
|
||||
fetchInactiveClaudeAccounts: () => Promise<void>
|
||||
fetchInactiveCodexAccounts: () => Promise<void>
|
||||
refreshMiniMax: () => Promise<RateLimitState>
|
||||
refreshGrok: () => Promise<RateLimitState>
|
||||
onUpdate: (callback: (state: RateLimitState) => void) => () => void
|
||||
}
|
||||
minimaxCredentials: {
|
||||
@@ -2883,6 +2892,9 @@ export type PreloadApi = {
|
||||
saveCookie: (cookie: string) => Promise<{ configured: boolean }>
|
||||
clearCookie: () => Promise<{ configured: boolean }>
|
||||
}
|
||||
grokAccounts: {
|
||||
getStatus: () => Promise<GrokAccountStatus>
|
||||
}
|
||||
ssh: {
|
||||
listTargets: () => Promise<SshTarget[]>
|
||||
// Removed-target id → last known label, for showing a friendly host name on
|
||||
|
||||
@@ -79,6 +79,7 @@ import type {
|
||||
} from '../shared/mobile-markdown-document'
|
||||
import type {
|
||||
CodexRateLimitResetResult,
|
||||
GrokAccountStatus,
|
||||
RateLimitRuntimeTarget,
|
||||
RateLimitState
|
||||
} from '../shared/rate-limit-types'
|
||||
@@ -156,7 +157,7 @@ import type {
|
||||
AutomationUpdateInput
|
||||
} from '../shared/automations-types'
|
||||
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
|
||||
import type { AiVaultListArgs } from '../shared/ai-vault-types'
|
||||
import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types'
|
||||
import type { AgentType } from '../shared/native-chat-types'
|
||||
import type {
|
||||
NativeChatAppendedMessages,
|
||||
@@ -3681,6 +3682,8 @@ const api = {
|
||||
aiVault: {
|
||||
listSessions: (args?: AiVaultListArgs): Promise<unknown> =>
|
||||
ipcRenderer.invoke('aiVault:listSessions', args),
|
||||
listSubagentSessions: (args: AiVaultSubagentListArgs): Promise<unknown> =>
|
||||
ipcRenderer.invoke('aiVault:listSubagentSessions', args),
|
||||
onWindowFocused: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => callback()
|
||||
ipcRenderer.on('aiVault:windowFocused', listener)
|
||||
@@ -3851,6 +3854,7 @@ const api = {
|
||||
fetchInactiveCodexAccounts: (): Promise<void> =>
|
||||
ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'),
|
||||
refreshMiniMax: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:refreshMiniMax'),
|
||||
refreshGrok: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:refreshGrok'),
|
||||
onUpdate: (callback: (state: RateLimitState) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state)
|
||||
ipcRenderer.on('rateLimits:update', listener)
|
||||
@@ -3867,6 +3871,10 @@ const api = {
|
||||
ipcRenderer.invoke('minimaxCredentials:clearCookie')
|
||||
},
|
||||
|
||||
grokAccounts: {
|
||||
getStatus: (): Promise<GrokAccountStatus> => ipcRenderer.invoke('grokAccounts:getStatus')
|
||||
},
|
||||
|
||||
ssh: {
|
||||
listTargets: (): Promise<SshTarget[]> => ipcRenderer.invoke('ssh:listTargets'),
|
||||
|
||||
|
||||
@@ -331,4 +331,51 @@ describe('RelayAgentHookServer', () => {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('caps the replay cache at 256 panes, evicting the least-recently-updated', async () => {
|
||||
// Mirrors the server's private MAX_CACHED_PANES. The WSL relay never gets a
|
||||
// per-pane teardown signal, so the cache is recency-capped instead.
|
||||
const CAP = 256
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
await server.start()
|
||||
try {
|
||||
const { port, token } = server.getCoordinates()
|
||||
const paneKeyFor = (i: number): string => makePaneKey(`tab-${i}`, LEAF_ID)
|
||||
const postPane = (paneKey: string): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey,
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'p' }
|
||||
})
|
||||
})
|
||||
|
||||
// Fill the cache to exactly the cap in insertion order 0..CAP-1. Sequential
|
||||
// awaits pin Map order = update recency, which the eviction relies on.
|
||||
for (let i = 0; i < CAP; i++) {
|
||||
await postPane(paneKeyFor(i))
|
||||
}
|
||||
// Refresh the OLDEST pane just before overflow, then push one more pane.
|
||||
// Recency (not insertion) order must now evict pane 1, sparing pane 0.
|
||||
await postPane(paneKeyFor(0))
|
||||
await postPane(paneKeyFor(CAP))
|
||||
|
||||
forward.mockClear()
|
||||
const replayed = server.replayCachedPayloadsForPanes()
|
||||
expect(replayed).toBe(CAP)
|
||||
|
||||
const cachedPaneKeys = new Set(forward.mock.calls.map((call) => call[0].paneKey))
|
||||
expect(cachedPaneKeys.size).toBe(CAP)
|
||||
expect(cachedPaneKeys.has(paneKeyFor(0))).toBe(true)
|
||||
expect(cachedPaneKeys.has(paneKeyFor(CAP))).toBe(true)
|
||||
expect(cachedPaneKeys.has(paneKeyFor(1))).toBe(false)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
@@ -54,6 +54,12 @@ const ASSISTANT_MESSAGE_RETRY_MS = 50
|
||||
// '1'/'999'); anything longer is treated as absent.
|
||||
const MAX_HOOK_META_LEN = 64
|
||||
|
||||
// Why: the WSL relay has no per-pane teardown signal (PTYs live on the
|
||||
// Windows host, so nothing calls clearPaneState), and the replay cache would
|
||||
// otherwise grow for the relay's lifetime. Recency-cap it; backstop for the
|
||||
// SSH relay too.
|
||||
const MAX_CACHED_PANES = 256
|
||||
|
||||
function defaultEndpointDir(): string {
|
||||
return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR)
|
||||
}
|
||||
@@ -84,6 +90,15 @@ export type RelayHookServerOptions = {
|
||||
/** Env tag forwarded into hook payloads. Defaults to "remote", a relay
|
||||
* location marker that main excludes from dev-vs-prod mismatch warnings. */
|
||||
env?: string
|
||||
/** Fixed auth token. The WSL relay passes the host-issued token that
|
||||
* already crossed into guest env via WSLENV, so unmodified hook clients
|
||||
* authenticate without any re-coordination. Defaults to a fresh UUID. */
|
||||
token?: string
|
||||
/** Preferred bind port. The WSL relay passes the Windows listener's port —
|
||||
* free inside the guest under NAT, so env-sourced client coords stay
|
||||
* truthful. Occupied (e.g. mirrored networking) → fall back to :0 and rely
|
||||
* on the endpoint file for re-coordination. Defaults to :0. */
|
||||
preferredPort?: number
|
||||
/** Called once per parsed payload. The relay wires this to
|
||||
* `dispatcher.notify('agent.hook', envelope)`. */
|
||||
forward: RelayHookForward
|
||||
@@ -115,11 +130,16 @@ export class RelayAgentHookServer {
|
||||
> = new Map()
|
||||
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
private forward: RelayHookForward
|
||||
private fixedToken: string | undefined
|
||||
private preferredPort: number
|
||||
private portFallbackApplied = false
|
||||
|
||||
constructor(options: RelayHookServerOptions) {
|
||||
this.env = options.env ?? REMOTE_AGENT_HOOK_ENV
|
||||
this.endpointDir = options.endpointDir ?? defaultEndpointDir()
|
||||
this.endpointFilePath = join(this.endpointDir, getEndpointFileName())
|
||||
this.fixedToken = options.token
|
||||
this.preferredPort = options.preferredPort ?? 0
|
||||
this.forward = options.forward
|
||||
}
|
||||
|
||||
@@ -127,10 +147,37 @@ export class RelayAgentHookServer {
|
||||
if (this.server) {
|
||||
return
|
||||
}
|
||||
this.token = randomUUID()
|
||||
this.token = this.fixedToken ?? randomUUID()
|
||||
this.endpointFileWritten = false
|
||||
this.portFallbackApplied = false
|
||||
try {
|
||||
await this.listenOn(this.preferredPort)
|
||||
} catch (err) {
|
||||
// Why: the preferred port is best-effort (WSL relay: the Windows
|
||||
// listener's port — occupied under mirrored networking, or by an
|
||||
// unrelated guest process). Fall back to an ephemeral port; clients
|
||||
// re-coordinate through the endpoint file.
|
||||
if (this.preferredPort > 0 && (err as NodeJS.ErrnoException)?.code === 'EADDRINUSE') {
|
||||
this.portFallbackApplied = true
|
||||
await this.listenOn(0)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
if (options.publishEndpoint !== false) {
|
||||
this.publishEndpointFile()
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the preferred port was occupied and the server fell back to
|
||||
* an ephemeral bind — diagnostics for the host-side relay manager. */
|
||||
get usedPortFallback(): boolean {
|
||||
return this.portFallbackApplied
|
||||
}
|
||||
|
||||
private listenOn(port: number): Promise<void> {
|
||||
this.server = createServer((req, res) => this.handleRequest(req, res))
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onStartupError = (err: Error): void => {
|
||||
this.server?.off('listening', onListening)
|
||||
// Why: null the server reference on bind failure so a subsequent
|
||||
@@ -149,16 +196,12 @@ export class RelayAgentHookServer {
|
||||
if (address && typeof address === 'object') {
|
||||
this.port = address.port
|
||||
}
|
||||
if (options.publishEndpoint !== false) {
|
||||
this.publishEndpointFile()
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
this.server!.once('error', onStartupError)
|
||||
// Why: bind 127.0.0.1:0 so the OS assigns a free port. Loopback only —
|
||||
// the agent CLI inside the same remote box reaches us via curl
|
||||
// 127.0.0.1:PORT; nobody outside the box can.
|
||||
this.server!.listen(0, '127.0.0.1', onListening)
|
||||
// Why: loopback only — the agent CLI inside the same remote box reaches
|
||||
// us via curl 127.0.0.1:PORT; nobody outside the box can.
|
||||
this.server!.listen(port, '127.0.0.1', onListening)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -332,8 +375,19 @@ export class RelayAgentHookServer {
|
||||
if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) {
|
||||
this.clearAssistantMessageRetry(event.paneKey)
|
||||
}
|
||||
// Why: delete-then-set keeps Map insertion order equal to last-update
|
||||
// recency, so the cache cap below always evicts the longest-idle pane.
|
||||
this.state.lastStatusByPaneKey.delete(event.paneKey)
|
||||
this.state.lastStatusByPaneKey.set(event.paneKey, event)
|
||||
this.lastEnvelopeMetaByPaneKey.delete(event.paneKey)
|
||||
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
|
||||
while (this.state.lastStatusByPaneKey.size > MAX_CACHED_PANES) {
|
||||
const oldest = this.state.lastStatusByPaneKey.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
this.clearPaneState(oldest)
|
||||
}
|
||||
this.forwardEvent(event, source, env, version)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
ingestGitGrepLine,
|
||||
SEARCH_TIMEOUT_MS
|
||||
} from '../shared/text-search'
|
||||
import { buildRelayCommandEnv } from './relay-command-env'
|
||||
import { buildRelayGitEnv } from './relay-command-env'
|
||||
|
||||
/**
|
||||
* List files using `git ls-files`. Fallback when rg is not installed.
|
||||
@@ -61,7 +61,7 @@ export function listFilesWithGit(
|
||||
|
||||
const child = spawn('git', ['ls-files', ...args], {
|
||||
cwd: rootPath,
|
||||
env: buildRelayCommandEnv(),
|
||||
env: buildRelayGitEnv(),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -209,7 +209,7 @@ export function searchWithGitGrep(
|
||||
|
||||
const child = spawn('git', gitArgs, {
|
||||
cwd: rootPath,
|
||||
env: buildRelayCommandEnv(),
|
||||
env: buildRelayGitEnv(),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let killTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
resolveEffectiveGitUpstream
|
||||
} from '../shared/git-effective-upstream'
|
||||
import { loadGitHistoryFromExecutor } from '../shared/git-history'
|
||||
import { buildRelayCommandEnv } from './relay-command-env'
|
||||
import { buildRelayGitEnv } from './relay-command-env'
|
||||
import {
|
||||
removeSafeUntrackedDiscardTarget,
|
||||
removeSafeUntrackedDiscardTargets
|
||||
@@ -254,7 +254,7 @@ export class GitHandler {
|
||||
stdin?: string
|
||||
}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const env = buildRelayCommandEnv()
|
||||
const env = buildRelayGitEnv()
|
||||
if (opts?.disableOptionalLocks) {
|
||||
env.GIT_OPTIONAL_LOCKS = '0'
|
||||
}
|
||||
@@ -281,7 +281,7 @@ export class GitHandler {
|
||||
private async gitBuffer(args: string[], cwd: string): Promise<Buffer> {
|
||||
const { stdout } = (await execFileAsync('git', args, {
|
||||
cwd,
|
||||
env: buildRelayCommandEnv(),
|
||||
env: buildRelayGitEnv(),
|
||||
encoding: 'buffer',
|
||||
maxBuffer: MAX_GIT_BUFFER
|
||||
})) as { stdout: Buffer }
|
||||
@@ -1097,7 +1097,7 @@ export class GitHandler {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn('git', args, {
|
||||
cwd: expandTilde(cwd),
|
||||
env: buildRelayCommandEnv(),
|
||||
env: buildRelayGitEnv(),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let stdout = ''
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { homedir } from 'node:os'
|
||||
import { buildRelayCommandEnv } from './relay-command-env'
|
||||
import { buildRelayCommandEnv, buildRelayGitEnv } from './relay-command-env'
|
||||
|
||||
// homedir() is the fallback when the relay env carries no HOME; mock it so the
|
||||
// fallback path is deterministic and the "no resolvable home" branch is reachable.
|
||||
@@ -228,3 +228,20 @@ describe('buildRelayCommandEnv', () => {
|
||||
expect(env.PATH?.split(':')).toContain('/home/fallback/.local/bin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRelayGitEnv', () => {
|
||||
it('pins an untranslated locale on top of the command env (issue #7808)', () => {
|
||||
// Relay git stderr/progress is machine-parsed; a non-English host locale
|
||||
// with a gettext git would translate it and break the parsers.
|
||||
const env = buildRelayGitEnv(
|
||||
{ HOME: '/home/me', PATH: '/usr/bin', LC_ALL: 'de_DE.UTF-8' },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.LC_ALL).toBe('en_US.UTF-8')
|
||||
expect(env.LANG).toBe('en_US.UTF-8')
|
||||
expect(env.LANGUAGE).toBe('en')
|
||||
// PATH fallback behavior is unchanged.
|
||||
expect(env.PATH?.split(':')).toEqual(expect.arrayContaining(['/usr/bin', '/usr/local/bin']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { posix, win32 } from 'node:path'
|
||||
import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../shared/git-output-locale'
|
||||
|
||||
const POSIX_RELAY_PATH_FALLBACKS = ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin']
|
||||
const WINDOWS_RELAY_PATH_FALLBACKS = [
|
||||
@@ -154,3 +155,16 @@ export function buildRelayCommandEnv(
|
||||
[key]: [...segments].join(delimiter)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Env for relay-spawned git specifically: the PATH fallbacks above plus a
|
||||
* pinned English UTF-8 locale, because relay git output is machine-parsed
|
||||
* (stderr phrase matching, progress regexes) and a gettext-enabled git under
|
||||
* a non-English host locale translates it (issue #7808).
|
||||
*/
|
||||
export function buildRelayGitEnv(
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): NodeJS.ProcessEnv {
|
||||
return { ...buildRelayCommandEnv(baseEnv, platform), ...UNTRANSLATED_GIT_OUTPUT_ENV }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// Guest-side WSL hook relay pieces: host-given port/token binding with
|
||||
// ephemeral fallback, and the shared endpoint-path contract.
|
||||
import { createServer } from 'node:net'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RelayAgentHookServer } from './agent-hook-server'
|
||||
import { makePaneKey } from '../shared/stable-pane-id'
|
||||
import {
|
||||
sanitizeWslHookInstanceKey,
|
||||
wslHookRelayEndpointDir,
|
||||
wslHookRelayEndpointFilePath
|
||||
} from '../shared/wsl-hook-relay-contract'
|
||||
|
||||
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const PANE_KEY = makePaneKey('tab-2', LEAF_ID)
|
||||
|
||||
// Parse an endpoint file tolerantly across platforms: POSIX writes `KEY=value`,
|
||||
// Windows writes `set KEY=value`. Strips the optional `set ` prefix.
|
||||
function parseEndpointFile(contents: string): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
for (const rawLine of contents.split(/\r?\n/)) {
|
||||
const line = rawLine.replace(/^set /, '')
|
||||
const eq = line.indexOf('=')
|
||||
if (eq > 0) {
|
||||
env[line.slice(0, eq)] = line.slice(eq + 1)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
describe('RelayAgentHookServer host-given coordinates (WSL relay)', () => {
|
||||
let tmpDir: string
|
||||
let server: RelayAgentHookServer | null
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'wsl-hook-relay-'))
|
||||
server = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server?.stop()
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('binds the preferred port with the fixed token when the port is free', async () => {
|
||||
const probe = createServer()
|
||||
const freePort = await new Promise<number>((resolve) => {
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const address = probe.address()
|
||||
resolve(typeof address === 'object' && address ? address.port : 0)
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()))
|
||||
|
||||
server = new RelayAgentHookServer({
|
||||
endpointDir: tmpDir,
|
||||
token: 'host-issued-token',
|
||||
preferredPort: freePort,
|
||||
forward: () => {}
|
||||
})
|
||||
await server.start()
|
||||
|
||||
expect(server.getCoordinates().port).toBe(freePort)
|
||||
expect(server.getCoordinates().token).toBe('host-issued-token')
|
||||
expect(server.usedPortFallback).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to an ephemeral port when the preferred port is occupied', async () => {
|
||||
const occupant = createServer()
|
||||
const occupiedPort = await new Promise<number>((resolve) => {
|
||||
occupant.listen(0, '127.0.0.1', () => {
|
||||
const address = occupant.address()
|
||||
resolve(typeof address === 'object' && address ? address.port : 0)
|
||||
})
|
||||
})
|
||||
try {
|
||||
server = new RelayAgentHookServer({
|
||||
endpointDir: tmpDir,
|
||||
token: 'host-issued-token',
|
||||
preferredPort: occupiedPort,
|
||||
forward: () => {}
|
||||
})
|
||||
await server.start()
|
||||
|
||||
expect(server.usedPortFallback).toBe(true)
|
||||
const bound = server.getCoordinates().port
|
||||
expect(bound).toBeGreaterThan(0)
|
||||
expect(bound).not.toBe(occupiedPort)
|
||||
// Fallback still authenticates with the host-issued token.
|
||||
expect(server.getCoordinates().token).toBe('host-issued-token')
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => occupant.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps random-token ephemeral behavior when no coordinates are given (SSH shape)', async () => {
|
||||
server = new RelayAgentHookServer({ endpointDir: tmpDir, forward: () => {} })
|
||||
await server.start()
|
||||
expect(server.getCoordinates().port).toBeGreaterThan(0)
|
||||
expect(server.getCoordinates().token).toMatch(/^[0-9a-f-]{36}$/)
|
||||
expect(server.usedPortFallback).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a wrong token but authenticates the host-issued fixed token', async () => {
|
||||
const forward = vi.fn()
|
||||
server = new RelayAgentHookServer({ endpointDir: tmpDir, token: 'host-issued-token', forward })
|
||||
await server.start()
|
||||
const { port } = server.getCoordinates()
|
||||
|
||||
const rejected = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': 'wrong-token' },
|
||||
body: '{}'
|
||||
})
|
||||
expect(rejected.status).toBe(403)
|
||||
expect(forward).not.toHaveBeenCalled()
|
||||
|
||||
const accepted = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': 'host-issued-token'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: PANE_KEY,
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' }
|
||||
})
|
||||
})
|
||||
expect(accepted.status).toBe(204)
|
||||
expect(forward).toHaveBeenCalledTimes(1)
|
||||
expect(forward.mock.calls[0][0].paneKey).toBe(PANE_KEY)
|
||||
})
|
||||
|
||||
it('publishes the fallback port + fixed token into the endpoint file after EADDRINUSE', async () => {
|
||||
const occupant = createServer()
|
||||
const occupiedPort = await new Promise<number>((resolve) => {
|
||||
occupant.listen(0, '127.0.0.1', () => {
|
||||
const address = occupant.address()
|
||||
resolve(typeof address === 'object' && address ? address.port : 0)
|
||||
})
|
||||
})
|
||||
try {
|
||||
server = new RelayAgentHookServer({
|
||||
endpointDir: tmpDir,
|
||||
token: 'host-issued-token',
|
||||
preferredPort: occupiedPort,
|
||||
forward: () => {}
|
||||
})
|
||||
await server.start()
|
||||
|
||||
expect(server.usedPortFallback).toBe(true)
|
||||
const { port, token, endpointFilePath } = server.getCoordinates()
|
||||
expect(port).not.toBe(occupiedPort)
|
||||
|
||||
// The endpoint file is the client re-coordination contract: surviving
|
||||
// agents re-source it and MUST see the actual fallback port, not the
|
||||
// occupied preferred port.
|
||||
const published = parseEndpointFile(readFileSync(endpointFilePath, 'utf8'))
|
||||
expect(published.ORCA_AGENT_HOOK_PORT).toBe(String(port))
|
||||
expect(published.ORCA_AGENT_HOOK_PORT).not.toBe(String(occupiedPort))
|
||||
expect(published.ORCA_AGENT_HOOK_TOKEN).toBe(token)
|
||||
expect(published.ORCA_AGENT_HOOK_TOKEN).toBe('host-issued-token')
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => occupant.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('wsl hook relay endpoint contract', () => {
|
||||
it('derives the endpoint dir from guest home and the restart-stable instance key', () => {
|
||||
expect(wslHookRelayEndpointDir('/home/u', 'abc123')).toBe(
|
||||
'/home/u/.orca-wsl/agent-hooks/instance-abc123'
|
||||
)
|
||||
expect(wslHookRelayEndpointDir('/home/u/', 'abc123')).toBe(
|
||||
'/home/u/.orca-wsl/agent-hooks/instance-abc123'
|
||||
)
|
||||
})
|
||||
|
||||
it('names the guest endpoint file endpoint.env regardless of host platform', () => {
|
||||
expect(wslHookRelayEndpointFilePath('/home/u', 'k1')).toBe(
|
||||
'/home/u/.orca-wsl/agent-hooks/instance-k1/endpoint.env'
|
||||
)
|
||||
})
|
||||
|
||||
it('sanitizes instance keys to a shell/path-inert alphabet', () => {
|
||||
expect(sanitizeWslHookInstanceKey('ABCdef012')).toBe('abcdef012')
|
||||
expect(sanitizeWslHookInstanceKey(' a-1 ')).toBe('a-1')
|
||||
expect(sanitizeWslHookInstanceKey('bad key$')).toBeNull()
|
||||
expect(sanitizeWslHookInstanceKey('')).toBeNull()
|
||||
expect(sanitizeWslHookInstanceKey(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
// Guest-resident WSL agent-hook relay (STA-1515). Runs inside a WSL distro,
|
||||
// binds a loopback hook receiver on the very port the Windows host issued
|
||||
// (free under NAT — that port only exists Windows-side), and forwards every
|
||||
// parsed hook envelope to Orca over this process's own stdin/stdout using the
|
||||
// framed JSON-RPC protocol the SSH relay already speaks. Also hosts the
|
||||
// home-scoped fs bridge the host uses to install hook configs into the guest.
|
||||
//
|
||||
// Lifecycle: dies when stdin closes. A lingering guest listener would let
|
||||
// WSL's Windows→WSL forwarder grab the freed Windows-side port and blackhole
|
||||
// stale Windows-side hook posts — so unlike the SSH relay there is no grace
|
||||
// period and no daemon socket.
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
import { RELAY_SENTINEL } from './protocol'
|
||||
import { RelayDispatcher } from './dispatcher'
|
||||
import { RelayAgentHookServer } from './agent-hook-server'
|
||||
import { registerWslHookFsHandlers } from './wsl-hook-fs-bridge'
|
||||
import {
|
||||
AGENT_HOOK_NOTIFICATION_METHOD,
|
||||
AGENT_HOOK_REQUEST_REPLAY_METHOD
|
||||
} from '../shared/agent-hook-relay'
|
||||
import {
|
||||
sanitizeWslHookInstanceKey,
|
||||
WSL_HOOK_RELAY_INSTANCE_ENV,
|
||||
wslHookRelayEndpointDir
|
||||
} from '../shared/wsl-hook-relay-contract'
|
||||
|
||||
async function main(): Promise<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)
|
||||
}
|
||||
|
||||
let stdoutAlive = true
|
||||
const dispatcher = new RelayDispatcher((data) => {
|
||||
if (!stdoutAlive) {
|
||||
return
|
||||
}
|
||||
return process.stdout.write(data)
|
||||
})
|
||||
|
||||
// Why: restart-stable instance key keeps the endpoint file at one path
|
||||
// across app restarts so surviving agents re-coordinate off its rewrite.
|
||||
const instanceKey =
|
||||
sanitizeWslHookInstanceKey(process.env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? `port${windowsPort}`
|
||||
const hookServer = new RelayAgentHookServer({
|
||||
endpointDir: wslHookRelayEndpointDir(homedir(), instanceKey),
|
||||
token,
|
||||
preferredPort: windowsPort,
|
||||
forward: (envelope) =>
|
||||
dispatcher.notify(
|
||||
AGENT_HOOK_NOTIFICATION_METHOD,
|
||||
envelope as unknown as Record<string, unknown>
|
||||
)
|
||||
})
|
||||
|
||||
dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({
|
||||
replayed: hookServer.replayCachedPayloadsForPanes()
|
||||
}))
|
||||
registerWslHookFsHandlers(dispatcher, homedir(), () => ({
|
||||
portFallback: hookServer.usedPortFallback,
|
||||
boundPort: hookServer.getCoordinates().port
|
||||
}))
|
||||
|
||||
try {
|
||||
await hookServer.start()
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[wsl-hook-relay] hook server bind failed: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
if (hookServer.usedPortFallback) {
|
||||
// Why: diagnosable breadcrumb — hook clients are fail-open silent, the
|
||||
// relay must not be. Fallback is expected under mirrored networking.
|
||||
process.stderr.write(
|
||||
`[wsl-hook-relay] port ${windowsPort} occupied; bound ${hookServer.getCoordinates().port} (endpoint-file re-coordination)\n`
|
||||
)
|
||||
}
|
||||
|
||||
const shutdown = (): void => {
|
||||
stdoutAlive = false
|
||||
dispatcher.dispose()
|
||||
hookServer.stop()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.stdin.on('data', (chunk: Buffer) => dispatcher.feed(chunk))
|
||||
process.stdin.on('end', shutdown)
|
||||
process.stdin.on('error', shutdown)
|
||||
process.stdout.on('error', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
process.on('SIGINT', shutdown)
|
||||
// Why: same posture as the SSH relay — an uncaught exception may leave
|
||||
// broken invariants, so exit and let the host manager respawn a clean
|
||||
// relay; a stray rejection is logged and survived (hook delivery must not
|
||||
// die for a non-fatal async error).
|
||||
process.on('uncaughtException', (err) => {
|
||||
process.stderr.write(`[wsl-hook-relay] uncaught exception: ${err.message}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
process.stderr.write(`[wsl-hook-relay] unhandled rejection: ${String(reason)}\n`)
|
||||
})
|
||||
|
||||
// Signal readiness — the host watches for this exact string before
|
||||
// sending framed data (same contract as the SSH relay).
|
||||
process.stdout.write(RELAY_SENTINEL)
|
||||
}
|
||||
|
||||
void main()
|
||||
@@ -0,0 +1,143 @@
|
||||
// Why: the fs bridge is POSIX-by-design — it resolves every path with
|
||||
// `posix.resolve` against a POSIX guest home. On win32 `posix.resolve` of a
|
||||
// Windows tmpdir yields an invalid path, so the whole suite is skipped there
|
||||
// (the bridge only ever runs inside a Linux WSL guest).
|
||||
import { mkdtempSync, rmSync, statSync } from 'node:fs'
|
||||
import { posix } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { registerWslHookFsHandlers } from './wsl-hook-fs-bridge'
|
||||
import type { MethodHandler, RelayDispatcher, RequestContext } from './dispatcher'
|
||||
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../shared/wsl-hook-relay-contract'
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('registerWslHookFsHandlers (WSL fs bridge)', () => {
|
||||
let home: string
|
||||
let handlers: Map<string, MethodHandler>
|
||||
const context: RequestContext = { clientId: 1, isStale: () => false }
|
||||
|
||||
const call = async <T extends object = Record<string, unknown>>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {}
|
||||
): Promise<WslFsResult<T>> => {
|
||||
const handler = handlers.get(method)
|
||||
if (!handler) {
|
||||
throw new Error(`no handler registered for ${method}`)
|
||||
}
|
||||
return (await handler(params, context)) as WslFsResult<T>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(posix.join(tmpdir(), 'wsl-fs-home-'))
|
||||
handlers = new Map<string, MethodHandler>()
|
||||
// Capture handlers from a minimal fake dispatcher — registration only ever
|
||||
// calls onRequest, so a real RelayDispatcher is unnecessary.
|
||||
const dispatcher = {
|
||||
onRequest: (method: string, handler: MethodHandler) => {
|
||||
handlers.set(method, handler)
|
||||
}
|
||||
} as unknown as RelayDispatcher
|
||||
registerWslHookFsHandlers(dispatcher, home, () => ({ fallbackPort: 4321 }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns the resolved home and merges linkStatus extras', async () => {
|
||||
const result = await call<{ home: string; fallbackPort: number }>(WSL_HOOK_FS_METHODS.home)
|
||||
expect(result).toMatchObject({ ok: true, home: posix.resolve(home), fallbackPort: 4321 })
|
||||
})
|
||||
|
||||
it('round-trips writeFile + readFile inside home', async () => {
|
||||
const path = posix.join(home, 'note.txt')
|
||||
const write = await call(WSL_HOOK_FS_METHODS.writeFile, { path, content: 'hello guest' })
|
||||
expect(write.ok).toBe(true)
|
||||
const read = await call<{ content: string }>(WSL_HOOK_FS_METHODS.readFile, { path })
|
||||
expect(read).toEqual({ ok: true, content: 'hello guest' })
|
||||
})
|
||||
|
||||
it('refuses writeFile to an absolute path outside home', async () => {
|
||||
const result = await call(WSL_HOOK_FS_METHODS.writeFile, {
|
||||
path: '/etc/orca-evil.txt',
|
||||
content: 'x'
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
|
||||
})
|
||||
|
||||
it('refuses a `..` traversal that escapes home', async () => {
|
||||
const result = await call(WSL_HOOK_FS_METHODS.writeFile, {
|
||||
path: `${home}/../escape.txt`,
|
||||
content: 'x'
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
|
||||
})
|
||||
|
||||
it('refuses a sibling dir that shares home as a string prefix', async () => {
|
||||
// `${home}-evil/x` starts with homeRoot but not with `${homeRoot}/`.
|
||||
const result = await call(WSL_HOOK_FS_METHODS.writeFile, {
|
||||
path: `${home}-evil/x.txt`,
|
||||
content: 'x'
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
|
||||
})
|
||||
|
||||
it('reports ENOENT for a missing file inside home', async () => {
|
||||
const result = await call(WSL_HOOK_FS_METHODS.readFile, {
|
||||
path: posix.join(home, 'does-not-exist.txt')
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, errno: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('allows readdir existence probes on an ancestor of home and on /', async () => {
|
||||
const ancestor = await call<{ entries: { filename: string }[] }>(WSL_HOOK_FS_METHODS.readdir, {
|
||||
path: posix.dirname(home)
|
||||
})
|
||||
expect(ancestor.ok).toBe(true)
|
||||
const root = await call<{ entries: { filename: string }[] }>(WSL_HOOK_FS_METHODS.readdir, {
|
||||
path: '/'
|
||||
})
|
||||
expect(root.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses readdir on a non-ancestor dir outside home', async () => {
|
||||
const result = await call(WSL_HOOK_FS_METHODS.readdir, { path: '/etc' })
|
||||
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
|
||||
})
|
||||
|
||||
it('refuses rename crossing the home boundary in either direction', async () => {
|
||||
const inside = posix.join(home, 'src.txt')
|
||||
await call(WSL_HOOK_FS_METHODS.writeFile, { path: inside, content: 'x' })
|
||||
const outbound = await call(WSL_HOOK_FS_METHODS.rename, {
|
||||
src: inside,
|
||||
dst: '/etc/orca-evil.txt'
|
||||
})
|
||||
expect(outbound).toMatchObject({ ok: false, errno: 'EACCES' })
|
||||
const inbound = await call(WSL_HOOK_FS_METHODS.rename, {
|
||||
src: '/etc/passwd',
|
||||
dst: posix.join(home, 'stolen.txt')
|
||||
})
|
||||
expect(inbound).toMatchObject({ ok: false, errno: 'EACCES' })
|
||||
})
|
||||
|
||||
it('refuses a relative path (resolved against cwd, which lands outside home)', async () => {
|
||||
const result = await call(WSL_HOOK_FS_METHODS.readFile, { path: 'foo.txt' })
|
||||
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
|
||||
})
|
||||
|
||||
it('refuses mkdir outside home and creates a dir inside home', async () => {
|
||||
const outside = await call(WSL_HOOK_FS_METHODS.mkdir, { path: '/etc/orca-evil-dir' })
|
||||
expect(outside).toMatchObject({ ok: false, errno: 'EACCES' })
|
||||
const dir = posix.join(home, 'newdir')
|
||||
const inside = await call(WSL_HOOK_FS_METHODS.mkdir, { path: dir })
|
||||
expect(inside.ok).toBe(true)
|
||||
expect(statSync(dir).isDirectory()).toBe(true)
|
||||
})
|
||||
|
||||
it('fails without crashing when chmod gets a non-numeric mode', async () => {
|
||||
const path = posix.join(home, 'perm.txt')
|
||||
await call(WSL_HOOK_FS_METHODS.writeFile, { path, content: 'x' })
|
||||
const result = await call(WSL_HOOK_FS_METHODS.chmod, { path, mode: 'not-a-number' })
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
// Guest-side fs bridge for the WSL agent-hook relay. Exposes the handful of
|
||||
// home-scoped file operations the shared remote hook installers need, as
|
||||
// JSON-RPC request handlers over the relay's stdio channel — so the host
|
||||
// installs hook configs/scripts into the distro without per-file wsl.exe
|
||||
// spawns (load-sensitive, see docs/agent-status-over-wsl.md).
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { posix } from 'node:path'
|
||||
|
||||
import type { RelayDispatcher } from './dispatcher'
|
||||
import {
|
||||
WSL_HOOK_FS_METHODS,
|
||||
type WslFsFailure,
|
||||
type WslFsResult
|
||||
} from '../shared/wsl-hook-relay-contract'
|
||||
|
||||
function failure(err: unknown): WslFsFailure {
|
||||
const e = err as NodeJS.ErrnoException
|
||||
return { ok: false, errno: e?.code ?? 'EUNKNOWN', message: e?.message ?? String(err) }
|
||||
}
|
||||
|
||||
export function registerWslHookFsHandlers(
|
||||
dispatcher: RelayDispatcher,
|
||||
home: string,
|
||||
// Why: the home request doubles as the connect handshake; link diagnostics
|
||||
// (port fallback) ride it so the host can breadcrumb them.
|
||||
linkStatus?: () => Record<string, unknown>
|
||||
): void {
|
||||
// Why: the bridge exists solely to write agent hook configs into the guest
|
||||
// user's home. Refusing paths outside it bounds the blast radius of a
|
||||
// compromised host-side caller to what hook installation touches anyway.
|
||||
// The bound is lexical (symlinks inside home are followed) — acceptable
|
||||
// because the only caller is the host-owned stdio channel, never an
|
||||
// agent-reachable surface.
|
||||
const homeRoot = posix.resolve(home)
|
||||
const resolveRaw = (rawPath: unknown): string => {
|
||||
if (typeof rawPath !== 'string' || rawPath.length === 0) {
|
||||
throw Object.assign(new Error('invalid path'), { code: 'EINVAL' })
|
||||
}
|
||||
return posix.resolve(rawPath)
|
||||
}
|
||||
const scoped = (rawPath: unknown): string => {
|
||||
const resolved = resolveRaw(rawPath)
|
||||
if (resolved !== homeRoot && !resolved.startsWith(`${homeRoot}/`)) {
|
||||
throw Object.assign(new Error(`path outside home: ${resolved}`), { code: 'EACCES' })
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
// Why: the installers' mkdir-p walks top-down from `/`, probing every
|
||||
// ancestor of home with readdir before it ever creates a dir. Allow
|
||||
// read-only existence probes on those ancestors; everything else stays
|
||||
// home-scoped.
|
||||
const scopedProbe = (rawPath: unknown): string => {
|
||||
const resolved = resolveRaw(rawPath)
|
||||
if (resolved === '/' || homeRoot === resolved || homeRoot.startsWith(`${resolved}/`)) {
|
||||
return resolved
|
||||
}
|
||||
return scoped(rawPath)
|
||||
}
|
||||
|
||||
dispatcher.onRequest(
|
||||
WSL_HOOK_FS_METHODS.home,
|
||||
async (): Promise<WslFsResult<{ home: string }>> => {
|
||||
return { ok: true, home: homeRoot, ...linkStatus?.() }
|
||||
}
|
||||
)
|
||||
|
||||
dispatcher.onRequest(
|
||||
WSL_HOOK_FS_METHODS.readFile,
|
||||
async (params): Promise<WslFsResult<{ content: string }>> => {
|
||||
try {
|
||||
const content = await fs.readFile(scoped(params.path), 'utf8')
|
||||
return { ok: true, content }
|
||||
} catch (err) {
|
||||
return failure(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
dispatcher.onRequest(WSL_HOOK_FS_METHODS.writeFile, async (params): Promise<WslFsResult> => {
|
||||
try {
|
||||
const mode = typeof params.mode === 'number' ? params.mode : undefined
|
||||
await fs.writeFile(scoped(params.path), String(params.content ?? ''), {
|
||||
encoding: 'utf8',
|
||||
mode
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
return failure(err)
|
||||
}
|
||||
})
|
||||
|
||||
dispatcher.onRequest(
|
||||
WSL_HOOK_FS_METHODS.stat,
|
||||
async (params): Promise<WslFsResult<{ mode: number }>> => {
|
||||
try {
|
||||
const stats = await fs.stat(scoped(params.path))
|
||||
return { ok: true, mode: stats.mode }
|
||||
} catch (err) {
|
||||
return failure(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
dispatcher.onRequest(WSL_HOOK_FS_METHODS.rename, async (params): Promise<WslFsResult> => {
|
||||
try {
|
||||
// Why: POSIX rename overwrites atomically — exactly the OpenSSH
|
||||
// overwrite-rename semantics the installers prefer.
|
||||
await fs.rename(scoped(params.src), scoped(params.dst))
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
return failure(err)
|
||||
}
|
||||
})
|
||||
|
||||
dispatcher.onRequest(WSL_HOOK_FS_METHODS.unlink, async (params): Promise<WslFsResult> => {
|
||||
try {
|
||||
await fs.unlink(scoped(params.path))
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
return failure(err)
|
||||
}
|
||||
})
|
||||
|
||||
dispatcher.onRequest(WSL_HOOK_FS_METHODS.chmod, async (params): Promise<WslFsResult> => {
|
||||
try {
|
||||
await fs.chmod(scoped(params.path), Number(params.mode))
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
return failure(err)
|
||||
}
|
||||
})
|
||||
|
||||
dispatcher.onRequest(
|
||||
WSL_HOOK_FS_METHODS.readdir,
|
||||
async (params): Promise<WslFsResult<{ entries: { filename: string }[] }>> => {
|
||||
try {
|
||||
const names = await fs.readdir(scopedProbe(params.path))
|
||||
return { ok: true, entries: names.map((filename) => ({ filename })) }
|
||||
} catch (err) {
|
||||
return failure(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
dispatcher.onRequest(WSL_HOOK_FS_METHODS.mkdir, async (params): Promise<WslFsResult> => {
|
||||
try {
|
||||
await fs.mkdir(scoped(params.path))
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
return failure(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,9 @@ export type AgentDotState =
|
||||
| 'blocked'
|
||||
| 'waiting'
|
||||
| 'interrupted'
|
||||
// Why: AI Vault subagent rows report a transcript-derived failure, which is
|
||||
// an outcome (like 'done'), not a live attention state like 'blocked'.
|
||||
| 'failed'
|
||||
| 'done'
|
||||
| 'idle'
|
||||
// Why: the sidebar's title-based status flow (StatusIndicator/WorktreeCard)
|
||||
@@ -37,6 +40,8 @@ export function agentStateLabel(state: AgentDotState): string {
|
||||
return 'Waiting for input'
|
||||
case 'interrupted':
|
||||
return 'Interrupted'
|
||||
case 'failed':
|
||||
return 'Failed'
|
||||
case 'done':
|
||||
return 'Done'
|
||||
case 'idle':
|
||||
@@ -71,8 +76,11 @@ export const AgentStateDot = React.memo(function AgentStateDot({
|
||||
className={cn(
|
||||
// Why: match the sidebar worktree spinner's stepped cadence so
|
||||
// long-running visible agents do not keep a full-frame-rate loop.
|
||||
// Sized like the check icon (not the filled dots): both are
|
||||
// outline glyphs, so a smaller ring reads visibly undersized
|
||||
// next to a 'done' check in the same list.
|
||||
'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite]',
|
||||
inner
|
||||
icon
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
@@ -105,7 +113,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({
|
||||
inner,
|
||||
state === 'permission' || state === 'waiting'
|
||||
? 'bg-amber-500'
|
||||
: state === 'blocked' || state === 'interrupted'
|
||||
: state === 'blocked' || state === 'interrupted' || state === 'failed'
|
||||
? 'bg-red-500'
|
||||
: 'bg-neutral-500/40'
|
||||
)}
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
|
||||
const agentCmdOverrides = settings?.agentCmdOverrides
|
||||
const { getOriginalPaneTarget, jumpToOriginalPane, jumpToWorktree } =
|
||||
const { getOriginalPaneTarget, getSessionLiveState, jumpToOriginalPane, jumpToWorktree } =
|
||||
useAiVaultOriginalPaneActions()
|
||||
const [query, setQuery] = useState('')
|
||||
const [scope, setScope] = useState<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
|
||||
@@ -372,6 +372,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
getSessionResumeState={getSessionResumeState}
|
||||
getSessionResumeActions={getSessionResumeActions}
|
||||
getOriginalPaneTarget={getOriginalPaneTarget}
|
||||
getSessionLiveState={getSessionLiveState}
|
||||
getWorktreeInfo={(session) => sessionWorktreeById.get(session.id) ?? null}
|
||||
onToggleGroup={toggleGroup}
|
||||
onJumpToOriginalPane={jumpToOriginalPane}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { sessionDetailConversationTurns } from './ai-vault-session-display'
|
||||
import { SessionSubagentsSection } from './AiVaultSessionSubagents'
|
||||
import { SessionUnsavedConversationNotice } from './AiVaultSessionUnsavedNotice'
|
||||
import {
|
||||
aiVaultWorktreeCompactPath,
|
||||
@@ -97,6 +98,8 @@ export function SessionInlineDetails({
|
||||
<SessionUnsavedConversationNotice session={session} logAvailable={Boolean(onOpenLog)} />
|
||||
)}
|
||||
|
||||
<SessionSubagentsSection session={session} />
|
||||
|
||||
{shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, {
|
||||
vaultScope
|
||||
}) ? (
|
||||
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
SessionMetadata,
|
||||
SessionWorktreeLine
|
||||
} from './ai-vault-session-row-display'
|
||||
import type { AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
|
||||
export function VaultSessionRow({
|
||||
session,
|
||||
liveState,
|
||||
resumeStartup,
|
||||
worktreeInfo,
|
||||
vaultScope,
|
||||
@@ -50,6 +52,7 @@ export function VaultSessionRow({
|
||||
onOpenCwd
|
||||
}: {
|
||||
session: AiVaultSession
|
||||
liveState: AgentStatusState | null
|
||||
resumeStartup: AiVaultResumeStartup
|
||||
worktreeInfo: AiVaultSessionWorktreeInfo | null
|
||||
vaultScope: AiVaultScope
|
||||
@@ -178,6 +181,7 @@ export function VaultSessionRow({
|
||||
</div>
|
||||
<SessionMetadata
|
||||
session={session}
|
||||
liveState={liveState}
|
||||
updatedAt={updatedAt}
|
||||
worktreeInfo={worktreeInfo}
|
||||
vaultScope={vaultScope}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultSession, AiVaultSubagentListResult } from '../../../../shared/ai-vault-types'
|
||||
import { SessionSubagentsSection } from './AiVaultSessionSubagents'
|
||||
|
||||
const listSubagentSessions = vi.fn<(args: unknown) => Promise<AiVaultSubagentListResult>>()
|
||||
|
||||
beforeEach(() => {
|
||||
listSubagentSessions.mockReset()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only window.api shim
|
||||
;(window as any).api = {
|
||||
aiVault: { listSubagentSessions },
|
||||
shell: { openFilePath: vi.fn() }
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
function makeSession(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
|
||||
return {
|
||||
id: 'local:claude:parent-session:/tmp/parent-session.jsonl',
|
||||
executionHostId: 'local',
|
||||
agent: 'claude',
|
||||
sessionId: 'parent-session',
|
||||
title: 'Parent session',
|
||||
cwd: '/repo',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: '/tmp/parent-session.jsonl',
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
modifiedAt: '2026-07-05T10:00:00.000Z',
|
||||
messageCount: 3,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 1,
|
||||
resumeCommand: 'claude --resume parent-session',
|
||||
subagent: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeSubagent(title: string): AiVaultSession {
|
||||
return makeSession({
|
||||
id: `local:claude:parent-session:/tmp/parent-session/subagents/agent-${title}.jsonl`,
|
||||
title,
|
||||
filePath: `/tmp/parent-session/subagents/agent-${title}.jsonl`,
|
||||
subagent: { parentSessionId: 'parent-session', agentType: null, status: 'running' },
|
||||
subagentTranscriptCount: 0
|
||||
})
|
||||
}
|
||||
|
||||
describe('SessionSubagentsSection', () => {
|
||||
it('keeps the loaded list visible while a rescan-triggered refetch is in flight', async () => {
|
||||
listSubagentSessions.mockResolvedValueOnce({
|
||||
sessions: [makeSubagent('First pass')],
|
||||
issues: []
|
||||
})
|
||||
const { rerender, queryByText } = render(<SessionSubagentsSection session={makeSession()} />)
|
||||
await act(async () => {})
|
||||
expect(queryByText('First pass')).not.toBeNull()
|
||||
|
||||
// Second fetch stays pending: the previous rows must remain visible
|
||||
// instead of flickering back to the hidden loading state.
|
||||
let resolveSecond: (result: AiVaultSubagentListResult) => void = () => {}
|
||||
listSubagentSessions.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveSecond = resolve))
|
||||
)
|
||||
rerender(
|
||||
<SessionSubagentsSection session={makeSession({ modifiedAt: '2026-07-05T10:05:00.000Z' })} />
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(listSubagentSessions).toHaveBeenCalledTimes(2)
|
||||
expect(queryByText('First pass')).not.toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
resolveSecond({ sessions: [makeSubagent('Second pass')], issues: [] })
|
||||
})
|
||||
expect(queryByText('Second pass')).not.toBeNull()
|
||||
expect(queryByText('First pass')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fetch for remote sessions even when the scan counted transcripts', async () => {
|
||||
const { container } = render(
|
||||
<SessionSubagentsSection
|
||||
session={makeSession({ executionHostId: 'ssh:dev-box', subagentTranscriptCount: 2 })}
|
||||
/>
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(listSubagentSessions).not.toHaveBeenCalled()
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type React from 'react'
|
||||
import { Bot, FileJson } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot'
|
||||
import type { AiVaultSession, AiVaultSubagentRunStatus } from '../../../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type SubagentListState = { status: 'loading' } | { status: 'loaded'; sessions: AiVaultSession[] }
|
||||
|
||||
/**
|
||||
* Lists the Task subagent transcripts spawned by one session, fetched on
|
||||
* demand when the parent's details expand. Subagents share the parent's
|
||||
* sessionId and aren't independently resumable, so rows are view-only.
|
||||
*/
|
||||
export function SessionSubagentsSection({
|
||||
session
|
||||
}: {
|
||||
session: AiVaultSession
|
||||
}): React.JSX.Element | null {
|
||||
const subagents = useSubagentSessions(session)
|
||||
|
||||
if (subagents.status !== 'loaded' || subagents.sessions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
<span className="text-muted-foreground/80">
|
||||
<Bot className="size-3" />
|
||||
</span>
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionSubagents.subagentsCount',
|
||||
'Subagents ({{value0}})',
|
||||
{ value0: subagents.sessions.length }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{subagents.sessions.map((subagentSession) => (
|
||||
<SubagentSessionLine key={subagentSession.id} session={subagentSession} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function useSubagentSessions(session: AiVaultSession): SubagentListState {
|
||||
const [state, setState] = useState<SubagentListState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
// The scan already counted the transcripts; skip the IPC round-trip when
|
||||
// there is nothing to list. Remote sessions can carry a count (from the
|
||||
// remote walk listing), but their transcripts aren't local files to list.
|
||||
if (
|
||||
session.subagentTranscriptCount === 0 ||
|
||||
session.executionHostId !== LOCAL_EXECUTION_HOST_ID
|
||||
) {
|
||||
setState({ status: 'loaded', sessions: [] })
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
// Why: rescans re-run this effect (modifiedAt changes); resetting to
|
||||
// loading would unmount the section until IPC returns and flicker on
|
||||
// every active-session rescan. Keep prior rows visible while refetching.
|
||||
setState((prev) => (prev.status === 'loaded' ? prev : { status: 'loading' }))
|
||||
window.api.aiVault
|
||||
.listSubagentSessions({
|
||||
agent: session.agent,
|
||||
parentFilePath: session.filePath,
|
||||
executionHostId: session.executionHostId
|
||||
})
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setState({ status: 'loaded', sessions: result.sessions })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed listing degrades to "no subagents" — the section stays hidden.
|
||||
if (!cancelled) {
|
||||
setState({ status: 'loaded', sessions: [] })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// Why: modifiedAt changes exactly when the parent transcript is rewritten,
|
||||
// so re-listing on it refreshes a subagent's status (e.g. running -> done).
|
||||
}, [
|
||||
session.agent,
|
||||
session.filePath,
|
||||
session.executionHostId,
|
||||
session.subagentTranscriptCount,
|
||||
session.modifiedAt
|
||||
])
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// AI Vault run statuses map onto the shared dot vocabulary: a completed Task
|
||||
// is an outcome ('done'), stopped/killed reads as an interruption.
|
||||
const SUBAGENT_DOT_STATES: Record<AiVaultSubagentRunStatus, AgentDotState> = {
|
||||
running: 'working',
|
||||
completed: 'done',
|
||||
failed: 'failed',
|
||||
stopped: 'interrupted'
|
||||
}
|
||||
|
||||
function SubagentSessionLine({ session }: { session: AiVaultSession }): React.JSX.Element {
|
||||
const dotState = session.subagent?.status ? SUBAGENT_DOT_STATES[session.subagent.status] : null
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5 rounded-md border border-sidebar-border/70 bg-sidebar-accent/25 px-2.5 py-1.5">
|
||||
{dotState ? (
|
||||
// Why: a plain inline span would baseline-align the dot; flex keeps it
|
||||
// vertically centered with the row text.
|
||||
<span className="flex shrink-0 items-center" title={agentStateLabel(dotState)}>
|
||||
<AgentStateDot state={dotState} />
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-[12px] leading-[1.35] text-foreground/90"
|
||||
title={session.title}
|
||||
>
|
||||
{session.title}
|
||||
</span>
|
||||
{session.subagent?.agentType ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 shrink-0 border-border/70 bg-background px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{session.subagent.agentType}
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionSubagents.messageCount',
|
||||
'{{value0}} msgs',
|
||||
{ value0: session.messageCount }
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
draggable={false}
|
||||
title={translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionSubagents.viewLog',
|
||||
'View Log'
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void window.api.shell.openFilePath(session.filePath)
|
||||
}}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
>
|
||||
<FileJson className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import type { AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -45,6 +46,7 @@ export function AiVaultSessionVirtualList({
|
||||
vaultScope,
|
||||
buildResumeStartup,
|
||||
getOriginalPaneTarget,
|
||||
getSessionLiveState,
|
||||
getWorktreeInfo,
|
||||
getSessionResumeState,
|
||||
getSessionResumeActions,
|
||||
@@ -68,6 +70,7 @@ export function AiVaultSessionVirtualList({
|
||||
vaultScope: AiVaultScope
|
||||
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
|
||||
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
|
||||
getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null
|
||||
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
|
||||
getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState
|
||||
getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions
|
||||
@@ -194,6 +197,7 @@ export function AiVaultSessionVirtualList({
|
||||
vaultScope={vaultScope}
|
||||
buildResumeStartup={buildResumeStartup}
|
||||
getOriginalPaneTarget={getOriginalPaneTarget}
|
||||
getSessionLiveState={getSessionLiveState}
|
||||
getWorktreeInfo={getWorktreeInfo}
|
||||
getSessionResumeState={getSessionResumeState}
|
||||
getSessionResumeActions={getSessionResumeActions}
|
||||
@@ -227,6 +231,7 @@ function AiVaultVirtualRow({
|
||||
vaultScope,
|
||||
buildResumeStartup,
|
||||
getOriginalPaneTarget,
|
||||
getSessionLiveState,
|
||||
getWorktreeInfo,
|
||||
getSessionResumeState,
|
||||
getSessionResumeActions,
|
||||
@@ -252,6 +257,7 @@ function AiVaultVirtualRow({
|
||||
vaultScope: AiVaultScope
|
||||
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
|
||||
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
|
||||
getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null
|
||||
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
|
||||
getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState
|
||||
getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions
|
||||
@@ -312,6 +318,7 @@ function AiVaultVirtualRow({
|
||||
) : (
|
||||
<VaultSessionRow
|
||||
session={row.session}
|
||||
liveState={getSessionLiveState(row.session)}
|
||||
resumeStartup={buildResumeStartup(row.session, resumeState?.worktreeId)}
|
||||
worktreeInfo={worktreeInfo}
|
||||
vaultScope={vaultScope}
|
||||
|
||||
@@ -4,14 +4,19 @@ import { toast } from 'sonner'
|
||||
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { findOriginalAiVaultSessionPane } from './ai-vault-original-pane'
|
||||
import {
|
||||
findAiVaultSessionLiveState,
|
||||
findOriginalAiVaultSessionPane
|
||||
} from './ai-vault-original-pane'
|
||||
|
||||
export function useAiVaultOriginalPaneActions(): {
|
||||
getOriginalPaneTarget: (
|
||||
session: AiVaultSession
|
||||
) => ReturnType<typeof findOriginalAiVaultSessionPane>
|
||||
getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null
|
||||
jumpToOriginalPane: (session: AiVaultSession) => void
|
||||
jumpToWorktree: (worktreeId: string) => void
|
||||
} {
|
||||
@@ -30,6 +35,11 @@ export function useAiVaultOriginalPaneActions(): {
|
||||
[originalPaneLookupState]
|
||||
)
|
||||
|
||||
const getSessionLiveState = useCallback(
|
||||
(session: AiVaultSession) => findAiVaultSessionLiveState(originalPaneLookupState, session),
|
||||
[originalPaneLookupState]
|
||||
)
|
||||
|
||||
const jumpToOriginalPane = useCallback((session: AiVaultSession): void => {
|
||||
const target = findOriginalAiVaultSessionPane(useAppStore.getState(), session)
|
||||
if (!target) {
|
||||
@@ -70,5 +80,5 @@ export function useAiVaultOriginalPaneActions(): {
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { getOriginalPaneTarget, jumpToOriginalPane, jumpToWorktree }
|
||||
return { getOriginalPaneTarget, getSessionLiveState, jumpToOriginalPane, jumpToWorktree }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user