From f26ea4e3099b8386d1406d61e1c7629c78ee4479 Mon Sep 17 00:00:00 2001 From: m4air Date: Sun, 27 Sep 2026 16:30:16 -0700 Subject: [PATCH] feat: run WSL helpers with a verified distro-local Bun runtime --- config/scripts/build-relay.mjs | 4 +- config/scripts/run-bundled-runtime-tests.mjs | 4 +- src/main/agent-hooks/wsl-hook-relay-deps.ts | 8 +- .../agent-hooks/wsl-hook-relay-launch.test.ts | 25 +- src/main/agent-hooks/wsl-hook-relay-launch.ts | 84 ++++--- .../wsl-hook-relay-live.integration.test.ts | 20 +- .../wsl-hook-relay-manager.test.ts | 69 +++++- .../agent-hooks/wsl-hook-relay-manager.ts | 8 +- .../wsl-hook-relay-recovery.test.ts | 66 ++++++ .../agent-hooks/wsl-hook-relay-recovery.ts | 20 +- .../opencode-wsl-runtime-preparation.test.ts | 62 +++-- .../opencode-wsl-runtime-preparation.ts | 115 +--------- ...sl-browser-network-bun.integration.test.ts | 71 ++++++ .../wsl-browser-network-relay-launch.test.ts | 9 +- .../wsl-browser-network-relay-launch.ts | 48 ++-- .../wsl-browser-network-running.test.ts | 63 ++++++ src/main/global-fetch-call-site-audit.test.ts | 2 + src/main/wsl/wsl-bun-browser-fixture.ts | 95 ++++++++ src/main/wsl/wsl-bun-hook-fixture.ts | 100 ++++++++ src/main/wsl/wsl-bun-relay-launchers.test.ts | 60 +++++ src/main/wsl/wsl-bun-runtime-running.test.ts | 97 ++++++++ src/main/wsl/wsl-bun-runtime.test.ts | 214 ++++++++++++++++++ src/main/wsl/wsl-bun-runtime.ts | 144 ++++++++++++ .../wsl-bun-runtime.win32.integration.test.ts | 148 ++++++++++++ src/relay/wsl-browser-network-relay.ts | 2 +- .../wsl-browser-network-relay-contract.ts | 2 +- src/shared/wsl-hook-relay-contract.ts | 4 +- 27 files changed, 1330 insertions(+), 214 deletions(-) create mode 100644 src/main/browser/wsl-browser-network-bun.integration.test.ts create mode 100644 src/main/browser/wsl-browser-network-running.test.ts create mode 100644 src/main/wsl/wsl-bun-browser-fixture.ts create mode 100644 src/main/wsl/wsl-bun-hook-fixture.ts create mode 100644 src/main/wsl/wsl-bun-relay-launchers.test.ts create mode 100644 src/main/wsl/wsl-bun-runtime-running.test.ts create mode 100644 src/main/wsl/wsl-bun-runtime.test.ts create mode 100644 src/main/wsl/wsl-bun-runtime.ts create mode 100644 src/main/wsl/wsl-bun-runtime.win32.integration.test.ts diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index f8d1852eff5..88ddd9c11c3 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -277,7 +277,7 @@ for (const platform of RELAY_BUILD_PLATFORMS) { entryPoints: [wslHookEntry], bundle: true, platform: 'node', - target: 'node18', + target: 'es2024', format: 'cjs', outfile: join(outDir, 'wsl-agent-hook-relay.js'), sourcemap: false, @@ -295,7 +295,7 @@ for (const platform of RELAY_BUILD_PLATFORMS) { entryPoints: [wslBrowserNetworkEntry], bundle: true, platform: 'node', - target: 'node18', + target: 'es2024', format: 'cjs', outfile: join(outDir, 'wsl-browser-network-relay.js'), sourcemap: false, diff --git a/config/scripts/run-bundled-runtime-tests.mjs b/config/scripts/run-bundled-runtime-tests.mjs index 7517bd5c828..2e42fc2bb34 100644 --- a/config/scripts/run-bundled-runtime-tests.mjs +++ b/config/scripts/run-bundled-runtime-tests.mjs @@ -34,5 +34,7 @@ run([ 'src/relay/ai-vault-memory-monitor.integration.test.ts', 'src/main/ai-vault-search/session-search-bun.integration.test.ts', 'src/main/ssh/ssh-relay-bun-runtime-commands.test.ts', - 'src/main/native-chat/wsl-transcript-bun.integration.test.ts' + 'src/main/native-chat/wsl-transcript-bun.integration.test.ts', + 'src/main/browser/wsl-browser-network-bun.integration.test.ts', + 'src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts' ]) diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index 77b2390747c..1ee3ee64114 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -1,3 +1,4 @@ +import { createRunningWslRuntimeRunner, ensureWslBunRuntime } from '../wsl/wsl-bun-runtime' // 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. @@ -32,9 +33,8 @@ 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 +// Avoid repeatedly preparing a runtime that failed to launch. +export const RUNTIME_UNAVAILABLE_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. @@ -60,6 +60,7 @@ export type WslHookRelayManagerDeps = { readBundle: (jsPath: string) => Buffer listDistros: () => Promise isDistroRunning: typeof isWslDistroRunning + prepareRuntime: (distro: string) => Promise spawnRelay: typeof spawnWslRelayProcess runInstall: typeof runWslInstallProcess waitForSentinel: typeof waitForWslRelaySentinel @@ -98,6 +99,7 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = { readBundle: (jsPath) => readFileSync(jsPath), listDistros: () => listWslDistrosAsync(), isDistroRunning: isWslDistroRunning, + prepareRuntime: (distro) => ensureWslBunRuntime(createRunningWslRuntimeRunner(distro)), spawnRelay: spawnWslRelayProcess, runInstall: runWslInstallProcess, waitForSentinel: waitForWslRelaySentinel, diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.test.ts b/src/main/agent-hooks/wsl-hook-relay-launch.test.ts index 6ed9cf2625a..6dcd93bdda8 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.test.ts @@ -4,22 +4,35 @@ const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn((..._args: unknown[]) => ({ pid: 1 })) })) -vi.mock('node:child_process', () => ({ spawn: spawnMock })) +vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: spawnMock })) -import { spawnWslRelayProcess } from './wsl-hook-relay-launch' +vi.mock('../wsl', () => ({ listRunningWslDistrosAsync: vi.fn() })) + +import { listRunningWslDistrosAsync } from '../wsl' +import { isWslDistroRunning, spawnWslRelayProcess } from './wsl-hook-relay-launch' describe('spawnWslRelayProcess', () => { it('names an explicit Windows directory rather than inheriting one', () => { - spawnWslRelayProcess('Ubuntu', {}, '1.2.3') + spawnWslRelayProcess('Ubuntu', {}, '1.2.3', '/home/ada/bun') // Why (#16463): the guest path is inside the `sh -c` command, so the Windows // cwd only decides whether CreateProcessW succeeds. Omitting it inherits // Orca's own — a `\\wsl.localhost` worktree the user can delete, after which // every relay launch fails `spawn wsl.exe ENOENT` for the rest of the session. expect(spawnMock).toHaveBeenCalledWith( - 'wsl.exe', - expect.arrayContaining(['-d', 'Ubuntu', '--exec']), - expect.objectContaining({ cwd: expect.any(String) }) + expect.objectContaining({ + program: 'wsl.exe', + args: expect.arrayContaining(['-d', 'Ubuntu', '--exec', '/home/ada/bun']), + cwd: expect.any(String) + }) ) }) }) + +it('requires confirmed running state instead of cached fallback membership', async () => { + vi.mocked(listRunningWslDistrosAsync).mockResolvedValueOnce(['Ubuntu']) + await expect(isWslDistroRunning('ubuntu')).resolves.toBe(true) + expect(listRunningWslDistrosAsync).toHaveBeenCalledWith({ requireConfirmed: true }) + vi.mocked(listRunningWslDistrosAsync).mockRejectedValueOnce(new Error('discovery unavailable')) + await expect(isWslDistroRunning('Ubuntu')).rejects.toThrow('discovery unavailable') +}) diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index ae1ea9c20d7..f0957e12355 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -3,7 +3,9 @@ // 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 { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import type { ChildProcessWithoutNullStreams } from 'node:child_process' +import { spawnProcess } from '../../shared/child-process/run-process' +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' @@ -22,7 +24,7 @@ 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_RUNTIME_UNAVAILABLE_EXIT_CODE, WSL_HOOK_RELAY_STALE_EXIT_CODE, WSL_HOOK_RELAY_VERSION_ENV, WSL_HOOK_RELAY_VERSION_FILE @@ -69,14 +71,13 @@ export function resolveWslHookRelayBundle(): WslHookRelayBundle | null { // 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}` + if (!/^[A-Za-z0-9+.-]+$/.test(version)) { + throw new Error('Invalid WSL hook relay version') + } + return `$HOME/${WSL_HOOK_RELAY_DIR}/bun/${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). */ +/** The host verifies Bun before passing its absolute path as the first argument. */ export function buildGuestLaunchScript(version: string): string { const dir = guestRelayDirExpr(version) return [ @@ -84,16 +85,10 @@ export function buildGuestLaunchScript(version: string): string { `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}"`, + 'runtime="$1"', + `case "$runtime" in /*) ;; *) exit ${WSL_HOOK_RELAY_RUNTIME_UNAVAILABLE_EXIT_CODE};; esac`, + `[ -x "$runtime" ] || exit ${WSL_HOOK_RELAY_RUNTIME_UNAVAILABLE_EXIT_CODE}`, + `exec "$runtime" "$d/${WSL_HOOK_RELAY_BUNDLE_NAME}"`, '' ].join('\n') } @@ -127,18 +122,19 @@ export function buildGuestInstallScript(bundleJs: Buffer, version: string): stri export function spawnWslRelayProcess( distro: string, env: NodeJS.ProcessEnv, - version: string + version: string, + executable: 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], { + const command = `exec sh "${guestRelayDirExpr(version)}/launch.sh" "$1"` + return spawnProcess({ + program: 'wsl.exe', + args: buildWslExecArgs(distro, ['sh', '-c', command, 'orca-hook-relay', executable]), env, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, // Why explicit (#16463): the guest path is in `command`, so the Windows cwd // only decides whether CreateProcessW succeeds -- and an inherited one is a // worktree the user can delete, which kills every later relay launch. @@ -148,14 +144,14 @@ export function spawnWslRelayProcess( /** 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 + * restart timer and launch loop check this before each guest operation. + * A stopped result is false; probe failures reject instead of using stale + * membership: 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 { const wanted = distro.trim().toLowerCase() - return listRunningWslDistrosAsync().then((running) => + return listRunningWslDistrosAsync({ requireConfirmed: true }).then((running) => running.some((candidate) => candidate.toLowerCase() === wanted) ) } @@ -188,6 +184,8 @@ export async function runWslInstallProcess( const TRANSIENT_RETRY_LIMIT = 2 export type WslRelayLaunchIo = { + isDistroRunning: typeof isWslDistroRunning + prepareRuntime: (distro: string) => Promise spawnRelay: typeof spawnWslRelayProcess waitForSentinel: typeof waitForWslRelaySentinel runInstall: typeof runWslInstallProcess @@ -198,7 +196,7 @@ export type WslRelayLaunchIo = { /** 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 + * with a missing runtime reports through `onRuntimeUnavailable`. Terminal failures report * through `onFailure`; non-startup errors propagate to the caller. */ export async function launchWslRelayWithInstall(options: { distro: string @@ -208,18 +206,35 @@ export async function launchWslRelayWithInstall(options: { io: WslRelayLaunchIo isDisposed: () => boolean onChild: (child: ChildProcessWithoutNullStreams) => void - onNoNode: () => void + onRuntimeUnavailable: () => void onFailure: (message: string) => void connect: (transport: MultiplexerTransport, child: ChildProcessWithoutNullStreams) => Promise }): Promise { const { distro, env, bundleJsPath, version, io } = options + if (options.isDisposed()) { + return + } + const requireRunning = async (): Promise => { + if (options.isDisposed()) { + return false + } + const running = await io.isDistroRunning(distro) + if (options.isDisposed()) { + return false + } + if (!running) { + options.onFailure(`distro '${distro}' is not running; relay setup deferred`) + } + return running + } + const executable = await io.prepareRuntime(distro) let installTried = false let transientRetries = 0 for (;;) { - if (options.isDisposed()) { + if (!(await requireRunning())) { return } - const child = io.spawnRelay(distro, env, version) + const child = io.spawnRelay(distro, env, version, executable) options.onChild(child) try { const transport = await io.waitForSentinel(child) @@ -235,8 +250,8 @@ export async function launchWslRelayWithInstall(options: { if (!failure) { throw err } - if (failure.code === WSL_HOOK_RELAY_NO_NODE_EXIT_CODE) { - options.onNoNode() + if (failure.code === WSL_HOOK_RELAY_RUNTIME_UNAVAILABLE_EXIT_CODE) { + options.onRuntimeUnavailable() return } if ( @@ -249,6 +264,9 @@ export async function launchWslRelayWithInstall(options: { } if (!installTried) { installTried = true + if (!(await requireRunning())) { + return + } const script = buildGuestInstallScript(io.readBundle(bundleJsPath), version) const result = await io.runInstall(distro, script, env) if (result.code === 0) { diff --git a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts index 365e4d5fcd8..600aab36605 100644 --- a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts @@ -1,12 +1,12 @@ // 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 +// bundle runs as a real child process (BUN_EXECUTABLE selects Bun 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 { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { createServer } from 'node:net' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' @@ -16,6 +16,7 @@ import { WslHookRelayManager } from './wsl-hook-relay-manager' import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' import { codexHookService } from '../codex/hook-service' +const RUNTIME = process.env.BUN_EXECUTABLE || process.execPath 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' @@ -61,6 +62,8 @@ describe.skipIf(process.platform === 'win32')( it('delivers a Claude hook POST from the live relay into ingestRemote and installs guest hooks', async () => { fakeHome = mkdtempSync(join('/tmp', 'wsl-live-home-')) + const fakeClaude = join(fakeHome, 'claude') + writeFileSync(fakeClaude, "#!/bin/sh\nprintf '2.1.130 (Claude Code)\\n'\n", { mode: 0o700 }) const preferredPort = await pickFreePort() const version = readFileSync(join(BUNDLE_DIR, '.version'), 'utf8').trim() @@ -89,11 +92,13 @@ describe.skipIf(process.platform === 'win32')( instanceKey: () => 'liveinstance', resolveBundle: () => ({ jsPath: BUNDLE_JS, version }), listDistros: async () => ['LiveDistro'], + prepareRuntime: async () => RUNTIME, + isDistroRunning: async () => true, spawnRelay: (_distro, env) => { - child = spawn(process.execPath, [BUNDLE_JS], { + child = spawn(RUNTIME, [BUNDLE_JS], { env: { ...env, HOME: fakeHome }, stdio: ['pipe', 'pipe', 'pipe'] - }) as ChildProcessWithoutNullStreams + }) return child }, runInstall: async () => { @@ -111,7 +116,7 @@ describe.skipIf(process.platform === 'win32')( }), managedHookSettings: () => ({ agentCmdOverrides: { - claude: process.execPath, + claude: fakeClaude, codex: process.execPath } }), @@ -127,7 +132,10 @@ describe.skipIf(process.platform === 'win32')( timeout: 15_000 }) await vi.waitFor( - () => expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true), + () => + expect(existsSync(join(fakeHome, '.claude', 'settings.json')), warns.join('\n')).toBe( + true + ), { timeout: 15_000 } ) const claudeScript = readFileSync( diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index c2f86ea8008..68ce8954c33 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -238,6 +238,7 @@ describe('WslHookRelayManager', () => { readBundle: () => Buffer.from('// bundle'), listDistros: async () => ['Ubuntu'], isDistroRunning: vi.fn(async () => true), + prepareRuntime: vi.fn(async () => '/home/test/bun'), spawnRelay: vi.fn(() => fakeChild()), runInstall: vi.fn(async () => ({ code: 0, stderr: '' })), waitForSentinel: vi.fn(async () => guestTransport()), @@ -473,12 +474,64 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) - it('gives up without installing when the guest has no node (43)', async () => { + it('does not launch or install when Bun provisioning fails', async () => { + const { manager, deps } = createManager({ + prepareRuntime: vi.fn().mockRejectedValue(new Error('runtime download failed')) + }) + manager.ensureForDistro('Ubuntu') + await vi.waitFor(() => + expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('runtime download failed')) + ) + expect(deps.spawnRelay).not.toHaveBeenCalled() + expect(deps.runInstall).not.toHaveBeenCalled() + manager.disposeAll() + }) + + it('does not start a distro that stopped while Bun was prepared', async () => { + const { manager, deps } = createManager({ isDistroRunning: vi.fn(async () => false) }) + await manager.ensureForDistro('Ubuntu') + expect(deps.prepareRuntime).toHaveBeenCalledOnce() + expect(deps.spawnRelay).not.toHaveBeenCalled() + expect(deps.runInstall).not.toHaveBeenCalled() + expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('not running')) + manager.disposeAll() + }) + + it('does not reinstall when the distro stops after the initial launch', async () => { + const { manager, deps } = createManager({ + isDistroRunning: vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false), + waitForSentinel: vi.fn().mockRejectedValue(startupError(42)) + }) + await manager.ensureForDistro('Ubuntu') + expect(deps.spawnRelay).toHaveBeenCalledOnce() + expect(deps.runInstall).not.toHaveBeenCalled() + manager.disposeAll() + }) + + it('does not launch after disposal while Bun is being prepared', async () => { + let complete: (runtime: string) => void = () => {} + const prepareRuntime = vi.fn( + () => + new Promise((resolve) => { + complete = resolve + }) + ) + const { manager, deps } = createManager({ prepareRuntime }) + manager.ensureForDistro('Ubuntu') + await vi.waitFor(() => expect(prepareRuntime).toHaveBeenCalledOnce()) + manager.disposeAll() + complete('/home/test/bun') + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(deps.spawnRelay).not.toHaveBeenCalled() + expect(deps.runInstall).not.toHaveBeenCalled() + }) + + it('gives up without installing when the guest runtime is unavailable (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.warn).toHaveBeenCalledWith(expect.stringContaining('bundled runtime unavailable')) ) expect(deps.runInstall).not.toHaveBeenCalled() // Cooldown: an immediate re-ensure must not spawn again. @@ -627,7 +680,15 @@ describe('WslHookRelayManager', () => { } } let resolveProbe: ((running: boolean) => void) | undefined - const isDistroRunning = vi.fn(() => new Promise((resolve) => (resolveProbe = resolve))) + const isDistroRunning = vi + .fn(async () => true) + .mockResolvedValueOnce(true) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveProbe = resolve + }) + ) const spawnRelay = vi.fn(() => fakeChild()) // First launch fails outright; the replacement launch never reaches the // sentinel, so its state stays 'starting' with no live mux to clean up. @@ -646,7 +707,7 @@ describe('WslHookRelayManager', () => { // Fire the restart timer; recovery blocks awaiting the distro-running probe. await vi.advanceTimersByTimeAsync(FAILURE_COOLDOWN_BASE_MS + 300) - expect(isDistroRunning).toHaveBeenCalledTimes(1) + expect(isDistroRunning).toHaveBeenCalledTimes(2) // 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. diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 2a2bdd4bba6..22dcd55dcd9 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -8,7 +8,7 @@ import { defaultWslHookRelayDeps, isWslHookRelayAllowed, FAILURE_COOLDOWN_BASE_MS, - NO_NODE_COOLDOWN_MS, + RUNTIME_UNAVAILABLE_COOLDOWN_MS, REINSTALL_ONE_SHOT_DELAY_MS, RUNNING_TEARDOWN_COOLDOWN_MS, STABLE_UPTIME_MS, @@ -195,11 +195,11 @@ export class WslHookRelayManager { onChild: (child) => { state.child = child }, - onNoNode: () => + onRuntimeUnavailable: () => this.markFailed( state, - `no node >= 18 found in distro '${state.distro}'; agent hooks stay degraded there`, - { cooldownBaseMs: NO_NODE_COOLDOWN_MS } + `bundled runtime unavailable in distro '${state.distro}'; agent hooks stay degraded there`, + { cooldownBaseMs: RUNTIME_UNAVAILABLE_COOLDOWN_MS } ), onFailure: (message) => this.markFailed(state, message, { diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts index 018d9101915..778b9e36172 100644 --- a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts @@ -153,3 +153,69 @@ describe('WslRelayRecovery', () => { expect(state.reinstallTimer).toBeUndefined() }) }) + +it('retries an inconclusive running probe without launching the distro', async () => { + vi.useFakeTimers() + try { + const probe = vi + .fn() + .mockRejectedValueOnce(new Error('discovery unavailable')) + .mockResolvedValue(true) + const restart = vi.fn() + const dropState = vi.fn() + const warn = vi.fn() + const recovery = new WslRelayRecovery({ + isDistroRunning: probe, + warn, + isDisposed: () => false, + isCurrent: () => true, + restart, + dropState + }) + const state = makeState() + recovery.scheduleRestart(state) + await vi.advanceTimersByTimeAsync(250) + expect(restart).not.toHaveBeenCalled() + expect(dropState).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('running-state probe failed')) + expect(state.restartTimer).toBeDefined() + await vi.advanceTimersByTimeAsync(60_250) + expect(probe).toHaveBeenCalledTimes(2) + expect(restart).toHaveBeenCalledWith('Ubuntu') + } finally { + vi.useRealTimers() + } +}) + +it.each(['replaced', 'disposed'])( + 'does not retry or mutate a %s state after a probe rejection', + async (reason) => { + vi.useFakeTimers() + try { + let finished = false + const restart = vi.fn() + const dropState = vi.fn() + const warn = vi.fn() + const recovery = new WslRelayRecovery({ + isDistroRunning: async () => { + finished = true + throw new Error('discovery unavailable') + }, + warn, + isDisposed: () => finished && reason === 'disposed', + isCurrent: () => !(finished && reason === 'replaced'), + restart, + dropState + }) + const state = makeState() + recovery.scheduleRestart(state) + await vi.advanceTimersByTimeAsync(250) + expect(restart).not.toHaveBeenCalled() + expect(dropState).not.toHaveBeenCalled() + expect(warn).not.toHaveBeenCalled() + expect(state.restartTimer).toBeUndefined() + } finally { + vi.useRealTimers() + } + } +) diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.ts index 71c8b4a22d5..bebc264d433 100644 --- a/src/main/agent-hooks/wsl-hook-relay-recovery.ts +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.ts @@ -1,6 +1,8 @@ // 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. +const RUNNING_PROBE_RETRY_DELAY_MS = 60_000 + export type WslRelayRecoveryState = { distro: string cooldownUntil: number @@ -62,7 +64,21 @@ export class WslRelayRecovery { if (this.io.isDisposed() || !this.io.isCurrent(state)) { return } - const running = await this.io.isDistroRunning(state.distro) + let running: boolean + try { + running = await this.io.isDistroRunning(state.distro) + } catch (error) { + if (this.io.isDisposed() || !this.io.isCurrent(state)) { + return + } + const detail = error instanceof Error ? error.message : String(error) + this.io.warn( + `[agent-hooks] WSL hook relay (${state.distro}): running-state probe failed; retrying in 60s: ${detail}` + ) + state.cooldownUntil = Date.now() + RUNNING_PROBE_RETRY_DELAY_MS + this.scheduleRestart(state) + return + } // 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. @@ -71,7 +87,7 @@ export class WslRelayRecovery { } 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)` + `[agent-hooks] WSL hook relay (${state.distro}): distro not running; restart skipped (next WSL terminal re-ensures)` ) this.io.dropState(state) return diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts index d78130a119f..6c53bf34d31 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts @@ -2,12 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { RELAY_OPENCODE_SQLITE_READER_FILENAME } from '../../shared/relay-artifacts' import { ORCAD_BUN_RELEASE_ASSETS } from '../../shared/orcad-bun-runtime' import { runProcess } from '../../shared/child-process/run-process' import type * as preparationModule from './opencode-wsl-runtime-preparation' const mocks = vi.hoisted(() => ({ - exists: vi.fn(() => true), + exists: vi.fn((_path: string) => true), run: vi.fn(), running: vi.fn(async (paths: readonly string[]) => [...paths]), download: vi.fn(async () => 'C:\\cache\\bun'), @@ -30,6 +31,7 @@ vi.mock('../ssh/orcad-bun-runtime-materializer', () => ({ vi.mock('../ssh/relay-bundle-paths', () => ({ relayBundleCandidates: mocks.bundles })) const home = String.raw`\\wsl.localhost\Ubuntu\home\ada` +const cachedRuntime = '/home/ada/.cache/orca/runtimes/verified/bun' const success = (stdout: string) => ({ stdout, stderr: '', code: 0, timedOut: false }) let prepare: typeof preparationModule.prepareOpenCodeWslReaders @@ -45,7 +47,13 @@ beforeEach(async () => { ? 'present' : spec.program === 'wslpath' ? '/mnt/c/reader.cjs' - : '/usr/bin/node' + : spec.program === 'uname' + ? 'x86_64' + : spec.script?.startsWith('getconf') + ? 'glibc 2.31' + : spec.script?.startsWith('printf') + ? '/home/ada' + : `__ORCA_BUN_READY__\n${cachedRuntime}\n` ) ) prepare = (await import('./opencode-wsl-runtime-preparation')).prepareOpenCodeWslReaders @@ -65,7 +73,7 @@ async function prepared() { } describe('WSL SQLite runtime preparation', () => { - it('returns immediately, coalesces distro aliases, and uses an actual guest SQLite probe', async () => { + it('returns immediately, coalesces distro aliases, and selects verified Bun without probing host Node', async () => { let release: (value: ReturnType) => void = () => {} mocks.run.mockReturnValueOnce( new Promise((resolve) => { @@ -78,15 +86,9 @@ describe('WSL SQLite runtime preparation', () => { expect(mocks.run).toHaveBeenCalledOnce() release(success('present')) expect(await prepared()).toEqual([ - { distro: 'ubuntu', executable: '/usr/bin/node', readerPath: '/mnt/c/reader.cjs' } + { distro: 'ubuntu', executable: cachedRuntime, readerPath: '/mnt/c/reader.cjs' } ]) - expect(mocks.run).toHaveBeenCalledWith( - expect.objectContaining({ - program: 'node', - loginPath: 'preferred', - args: ['-e', expect.stringContaining("require('node:sqlite')")] - }) - ) + expect(mocks.run.mock.calls.some(([spec]) => spec.program === 'node')).toBe(false) expect(mocks.bundles.mock.calls.map(([platform]) => platform)).toEqual([ 'linux-x64', 'linux-arm64' @@ -94,6 +96,20 @@ describe('WSL SQLite runtime preparation', () => { expect(mocks.download).not.toHaveBeenCalled() }) + it.each([ + join('C:/app', 'session-scanner-opencode-sqlite-process-entry.js'), + join('C:/relay/linux-x64', RELAY_OPENCODE_SQLITE_READER_FILENAME) + ])('prepares the available headless or desktop reader: %s', async (reader) => { + mocks.exists.mockImplementation((candidate) => candidate === reader) + await prepared() + expect(mocks.run).toHaveBeenCalledWith( + expect.objectContaining({ + program: 'wslpath', + args: ['-a', '-u', reader] + }) + ) + }) + it('does not start a stopped distro and retries a failed preparation after cooldown', async () => { let now = 10_000 vi.spyOn(Date, 'now').mockImplementation(() => now) @@ -104,13 +120,14 @@ describe('WSL SQLite runtime preparation', () => { mocks.running.mockImplementation(async (paths) => [...paths]) expect((await prepare([home]))[0]?.error).toContain('not running') now += 30_001 - expect((await prepared())[0]?.executable).toBe('/usr/bin/node') + expect((await prepared())[0]?.executable).toBe(cachedRuntime) }) it('keeps a working reader during revalidation and prunes completed removed distros', async () => { let now = 10_000 vi.spyOn(Date, 'now').mockImplementation(() => now) const ready = await prepared() + const previousCalls = mocks.run.mock.calls.length now += 600_001 let release: (value: ReturnType) => void = () => {} mocks.run.mockReturnValueOnce( @@ -119,7 +136,7 @@ describe('WSL SQLite runtime preparation', () => { }) ) expect(await prepare([home])).toEqual(ready) - await vi.waitFor(() => expect(mocks.run).toHaveBeenCalledTimes(4)) + await vi.waitFor(() => expect(mocks.run).toHaveBeenCalledTimes(previousCalls + 1)) expect(await prepare([home])).toEqual(ready) mocks.run.mockResolvedValueOnce(success('/mnt/c/repaired-reader.cjs')) release(success('present')) @@ -131,7 +148,7 @@ describe('WSL SQLite runtime preparation', () => { await prepared() }) - it('falls back to the pinned proxy runtime, verifies the guest stage, and preserves literal argv', async () => { + it('installs the pinned proxy runtime, verifies the guest stage, and preserves literal argv', async () => { const expected = ORCAD_BUN_RELEASE_ASSETS['linux-arm64-musl'].executableSha256 mocks.run.mockImplementation(async (spec) => { if (spec.script?.startsWith('data=')) { @@ -152,7 +169,11 @@ describe('WSL SQLite runtime preparation', () => { if (spec.script?.startsWith('printf')) { return success('/home/ada $literal') } - return success('') + return success( + spec.script?.includes('Uploaded Bun runtime checksum mismatch') + ? `__ORCA_BUN_READY__\n/home/ada $literal/.cache/orca/runtimes/${expected}/bun\n` + : '__ORCA_BUN_MISSING__\n' + ) }) const result = await prepared() expect(mocks.download).toHaveBeenCalledWith( @@ -160,13 +181,10 @@ describe('WSL SQLite runtime preparation', () => { expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }) ) - expect(result[0]?.executable).toBe( - `/home/ada $literal/.cache/orca/vault-sqlite/${expected}/bun` - ) + expect(result[0]?.executable).toBe(`/home/ada $literal/.cache/orca/runtimes/${expected}/bun`) expect(mocks.run).toHaveBeenCalledWith( expect.objectContaining({ - args: ['/mnt/c/reader $literal.cjs', result[0]?.executable, expected], - script: expect.stringContaining('actual=$(sha256sum -- "$stage")') + script: expect.stringContaining('Uploaded Bun runtime checksum mismatch') }) ) expect( @@ -196,7 +214,7 @@ describe('WSL SQLite runtime preparation', () => { if (spec.script?.startsWith('printf')) { return success('/home/ada') } - return success('') + return success('__ORCA_BUN_MISSING__\n') }) mocks.download.mockImplementationOnce(async () => { mocks.running.mockResolvedValue([]) @@ -227,7 +245,7 @@ describe('WSL SQLite runtime preparation', () => { expect(mocks.run.mock.calls[0]?.[0].script).toContain('OPENCODE_DB') expect(mocks.run.mock.calls[0]?.[0].script).toContain('XDG_DATA_HOME') now += 30_001 - expect((await prepared())[0]?.executable).toBe('/usr/bin/node') + expect((await prepared())[0]?.executable).toBe(cachedRuntime) expect(mocks.running).toHaveBeenCalledWith(expect.any(Array), { requireConfirmed: true }) }) diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts index c9bc81fff77..37d14f5c114 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts @@ -1,21 +1,13 @@ +import { createRunningWslRuntimeRunner, ensureWslBunRuntime } from '../wsl/wsl-bun-runtime' import { existsSync } from 'node:fs' import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import { ORCAD_BUN_RELEASE_ASSETS, type OrcadBunTarget } from '../../shared/orcad-bun-runtime' import { RELAY_OPENCODE_SQLITE_READER_FILENAME } from '../../shared/relay-artifacts' -import { parseWslUncPath, toWindowsWslUncPath } from '../../shared/wsl-paths' +import { parseWslUncPath } from '../../shared/wsl-paths' import { relayBundleCandidates } from '../ssh/relay-bundle-paths' -import { materializeCachedOrcadBunRuntime } from '../ssh/orcad-bun-runtime-materializer' -import { parseOrcadLinuxLibc } from '../ssh/orcad-deployment-target' -import { runWslProcess, type WslSpec } from '../wsl/wsl-runner' -import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime' const preparation = new Map() -const downloads = new Map>() -const PREPARATION_TIMEOUT_MS = 180_000 -const SQLITE_PROBE = `const db=new (require('node:sqlite').DatabaseSync)(':memory:');db.prepare('SELECT 1').get();db.close();process.stdout.write(process.execPath)` /** Only running distro roots enter here; a slow first install must not hold up local history. */ export async function prepareOpenCodeWslReaders( @@ -66,37 +58,16 @@ export async function prepareOpenCodeWslReaders( } async function prepare(distro: string): Promise { - const deadline = Date.now() + PREPARATION_TIMEOUT_MS - const signal = AbortSignal.timeout(PREPARATION_TIMEOUT_MS) - const run = async (spec: WslSpec): Promise => { - signal.throwIfAborted() - const running = await waitForPromiseWithSignal( - filterPathsToRunningWslDistrosAsync([toWindowsWslUncPath('/', distro)], { - requireConfirmed: true - }), - signal - ) - if (running.length === 0) { - throw new Error(`WSL distro ${distro} is not running. Start it to read its history.`) - } - signal.throwIfAborted() - const result = await runWslProcess({ - ...spec, - distro, - timeoutMs: Math.max(1, Math.min(15_000, deadline - Date.now())), - maxOutputBytes: 16 * 1024 - }) - if (result.code !== 0 || result.timedOut) { - throw new Error(`WSL SQLite reader setup failed: ${result.stderr.trim() || 'command failed'}`) - } - return result.stdout.trim() - } + const execution = createRunningWslRuntimeRunner(distro) + const { run } = execution const app = getAppEnvironment() // The reader is plain JavaScript; either packaged Linux architecture is usable. - const reader = (['linux-x64', 'linux-arm64'] as const) - .flatMap((platform) => relayBundleCandidates(platform, app.getAppPath())) - .map((directory) => join(directory, RELAY_OPENCODE_SQLITE_READER_FILENAME)) - .find(existsSync) + const reader = [ + join(app.getAppPath(), 'session-scanner-opencode-sqlite-process-entry.js'), + ...(['linux-x64', 'linux-arm64'] as const) + .flatMap((platform) => relayBundleCandidates(platform, app.getAppPath())) + .map((directory) => join(directory, RELAY_OPENCODE_SQLITE_READER_FILENAME)) + ].find(existsSync) if (!reader) { throw new Error('The bundled WSL SQLite reader is missing. Reinstall Orca to repair it.') } @@ -122,70 +93,6 @@ async function prepare(distro: string): Promise { args: ['-a', '-u', reader], loginPath: 'none' }) - let executable: string | null = null - try { - executable = await run({ program: 'node', args: ['-e', SQLITE_PROBE], loginPath: 'preferred' }) - } catch { - // Older guest Node remains supported; only the SQLite reader needs this runtime. - } - if (!executable?.startsWith('/')) { - const arch = await run({ program: 'uname', args: ['-m'], loginPath: 'none' }) - if (arch !== 'x86_64' && arch !== 'aarch64' && arch !== 'arm64') { - throw new Error(`Unsupported WSL SQLite reader architecture: ${arch}`) - } - const libc = parseOrcadLinuxLibc( - await run({ - script: - 'getconf GNU_LIBC_VERSION 2>/dev/null || ldd --version 2>&1 || ' + - 'for loader in /lib/ld-musl-*.so.1; do [ ! -e "$loader" ] || { echo musl; break; }; done', - loginPath: 'none' - }) - ) - const target = `linux-${arch === 'x86_64' ? 'x64' : 'arm64'}-${libc}` as const - const expected = ORCAD_BUN_RELEASE_ASSETS[target].executableSha256 - const home = await run({ script: 'printf %s "$HOME"', loginPath: 'none' }) - if (!home.startsWith('/')) { - throw new Error('WSL did not provide an absolute home directory.') - } - executable = `${home}/.cache/orca/vault-sqlite/${expected}/bun` - const present = await run({ - script: 'if [ -x "$1" ]; then sha256sum -- "$1"; fi', - args: [executable], - loginPath: 'none' - }) - if (!present.startsWith(`${expected} `)) { - let download = downloads.get(target) - if (!download) { - download = materializeCachedOrcadBunRuntime( - target, - join(app.getPath('userData'), 'orcad-artifacts'), - { - signal: AbortSignal.timeout(PREPARATION_TIMEOUT_MS) - } - ).finally(() => downloads.delete(target)) - downloads.set(target, download) - } - const localRuntime = await waitForPromiseWithSignal(download, signal) - const source = await run({ - program: 'wslpath', - args: ['-a', '-u', localRuntime], - loginPath: 'none' - }) - await run({ - script: [ - 'set -eu; umask 077', - 'mkdir -p -- "${2%/*}"', - 'stage=$(mktemp "${2}.upload.XXXXXX")', - 'trap \'rm -f -- "$stage"\' EXIT', - 'cp -- "$1" "$stage"', - 'actual=$(sha256sum -- "$stage"); [ "${actual%% *}" = "$3" ]', - 'chmod 700 "$stage"', - 'mv -f -- "$stage" "$2"' - ].join('\n'), - args: [source, executable, expected], - loginPath: 'none' - }) - } - } + const executable = await ensureWslBunRuntime(execution) return { distro, executable, readerPath } } diff --git a/src/main/browser/wsl-browser-network-bun.integration.test.ts b/src/main/browser/wsl-browser-network-bun.integration.test.ts new file mode 100644 index 00000000000..d6daee671a7 --- /dev/null +++ b/src/main/browser/wsl-browser-network-bun.integration.test.ts @@ -0,0 +1,71 @@ +import { createServer } from 'node:net' +import { once } from 'node:events' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { spawnProcess } from '../../shared/child-process/run-process' +import { resolveWslBrowserNetworkExecutionRoute } from './wsl-browser-network-execution-route' + +const runtime = process.env.BUN_EXECUTABLE +it.skipIf(!runtime)( + 'carries real TCP bytes through the built browser relay under Bun', + async () => { + if (!runtime) { + throw new Error('BUN_EXECUTABLE is required') + } + let tearingDown = false + const serverErrors: Error[] = [] + const server = createServer((socket) => { + socket.on('error', (error) => { + // Killing the relay may reset its TCP connection on Windows. + if (!tearingDown || !('code' in error) || error.code !== 'ECONNRESET') { + serverErrors.push(error) + } + }) + socket.pipe(socket) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Missing TCP listener') + } + const child = spawnProcess({ + program: runtime, + args: [join(process.cwd(), 'out/relay/wsl/wsl-browser-network-relay.js')], + env: { ORCA_BACKGROUND_LAUNCH: '1' } + }) + child.stderr.resume() + const closed = once(child, 'close') + const route = await resolveWslBrowserNetworkExecutionRoute( + { + executionHost: { + kind: 'wsl', + runtimeId: 'test-runtime', + revision: 1, + distro: 'test-distro' + }, + runtimeId: 'test-runtime', + runtimeRevision: 1 + }, + { launchRelay: async () => child } + ) + try { + const socket = route.connect({ host: '127.0.0.1', port: address.port }) + const received = new Promise((resolve, reject) => { + socket.on('error', reject) + socket.on('data', resolve) + socket.on('connect', () => socket.write(Buffer.from('Bun browser round trip'))) + }) + expect(Buffer.from(await received).toString()).toBe('Bun browser round trip') + tearingDown = true + socket.destroy() + } finally { + tearingDown = true + await route.close() + await closed + await new Promise((resolve) => server.close(() => resolve())) + expect(serverErrors).toEqual([]) + } + }, + 15_000 +) diff --git a/src/main/browser/wsl-browser-network-relay-launch.test.ts b/src/main/browser/wsl-browser-network-relay-launch.test.ts index cb9fe5c920a..4cb31f6fdf1 100644 --- a/src/main/browser/wsl-browser-network-relay-launch.test.ts +++ b/src/main/browser/wsl-browser-network-relay-launch.test.ts @@ -17,12 +17,13 @@ afterEach(() => { }) describe('WSL browser network relay launch', () => { - it('builds a shell-valid exact-version launcher with a Node 18 floor', () => { + it('builds a shell-valid exact-version launcher using the verified runtime argument', () => { const script = buildWslBrowserNetworkGuestLaunchScript('0.1.0+abc123') execFileSync('sh', ['-n'], { input: script }) - expect(script).toContain('.orca-wsl/browser-network/0.1.0+abc123') - expect(script).toContain('Number(process.versions.node.split(".")[0])>=18') + expect(script).toContain('.orca-wsl/browser-network/bun/0.1.0+abc123') + expect(script).toContain('runtime="$1"') + expect(script).not.toContain('command -v node') expect(script).toContain('wsl-browser-network-relay.js') expect(() => buildWslBrowserNetworkGuestLaunchScript("bad'version")).toThrow( 'browser_tunnel_execution_host_unavailable' @@ -38,7 +39,7 @@ describe('WSL browser network relay launch', () => { execFileSync('sh', ['-s'], { input: script, env: { ...process.env, HOME: root } }) - const installDir = join(root, '.orca-wsl', 'browser-network', version) + const installDir = join(root, '.orca-wsl', 'browser-network', 'bun', version) expect(readFileSync(join(installDir, 'wsl-browser-network-relay.js'))).toEqual(bundle) expect(readFileSync(join(installDir, '.browser-network-version'), 'utf8')).toBe(version) expect(readFileSync(join(installDir, 'launch.sh'), 'utf8')).toContain(version) diff --git a/src/main/browser/wsl-browser-network-relay-launch.ts b/src/main/browser/wsl-browser-network-relay-launch.ts index 8949f21e074..10a11beb8fc 100644 --- a/src/main/browser/wsl-browser-network-relay-launch.ts +++ b/src/main/browser/wsl-browser-network-relay-launch.ts @@ -1,3 +1,9 @@ +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' +import { + assertWslRuntimeDistroRunning, + createRunningWslRuntimeRunner, + ensureWslBunRuntime +} from '../wsl/wsl-bun-runtime' import { spawnProcess } from '../../shared/child-process/run-process' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' @@ -5,7 +11,7 @@ import { getAppEnvironment } from '../../shared/app-environment' import { WSL_BROWSER_NETWORK_RELAY_BUNDLE_NAME, WSL_BROWSER_NETWORK_RELAY_DIR, - WSL_BROWSER_NETWORK_RELAY_NO_NODE_EXIT_CODE, + WSL_BROWSER_NETWORK_RELAY_RUNTIME_UNAVAILABLE_EXIT_CODE, WSL_BROWSER_NETWORK_RELAY_SENTINEL, WSL_BROWSER_NETWORK_RELAY_STALE_EXIT_CODE, WSL_BROWSER_NETWORK_RELAY_VERSION_FILE @@ -61,7 +67,7 @@ function guestRelayDir(version: string): string { if (!/^[A-Za-z0-9+.-]+$/.test(version)) { throw new Error('browser_tunnel_execution_host_unavailable') } - return `$HOME/${WSL_BROWSER_NETWORK_RELAY_DIR}/${version}` + return `$HOME/${WSL_BROWSER_NETWORK_RELAY_DIR}/bun/${version}` } export function buildWslBrowserNetworkGuestLaunchScript(version: string): string { @@ -71,16 +77,10 @@ export function buildWslBrowserNetworkGuestLaunchScript(version: string): string `d="${dir}"`, `v="$(cat "$d/${WSL_BROWSER_NETWORK_RELAY_VERSION_FILE}" 2>/dev/null || true)"`, `[ "$v" = '${version}' ] || exit ${WSL_BROWSER_NETWORK_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_BROWSER_NETWORK_RELAY_NO_NODE_EXIT_CODE}`, - `exec "$n" "$d/${WSL_BROWSER_NETWORK_RELAY_BUNDLE_NAME}"`, + 'runtime="$1"', + `case "$runtime" in /*) ;; *) exit ${WSL_BROWSER_NETWORK_RELAY_RUNTIME_UNAVAILABLE_EXIT_CODE};; esac`, + `[ -x "$runtime" ] || exit ${WSL_BROWSER_NETWORK_RELAY_RUNTIME_UNAVAILABLE_EXIT_CODE}`, + `exec "$runtime" "$d/${WSL_BROWSER_NETWORK_RELAY_BUNDLE_NAME}"`, '' ].join('\n') } @@ -117,19 +117,26 @@ export async function launchWslBrowserNetworkRelay( if (!bundle || signal.aborted) { throw new Error('browser_tunnel_execution_host_unavailable') } + const executable = await ensureWslBunRuntime(createRunningWslRuntimeRunner(distro, signal)) + signal.throwIfAborted() let installTried = false for (;;) { - const attempt = await startWslBrowserNetworkRelay(distro, bundle.version, signal) + await assertWslRuntimeDistroRunning(distro, signal) + const attempt = await startWslBrowserNetworkRelay(distro, bundle.version, executable, signal) if (attempt.child) { return attempt.child } - if (signal.aborted || attempt.code === WSL_BROWSER_NETWORK_RELAY_NO_NODE_EXIT_CODE) { + if ( + signal.aborted || + attempt.code === WSL_BROWSER_NETWORK_RELAY_RUNTIME_UNAVAILABLE_EXIT_CODE + ) { throw new Error('browser_tunnel_execution_host_unavailable') } if (installTried) { throw new Error('browser_tunnel_execution_host_unavailable') } installTried = true + await assertWslRuntimeDistroRunning(distro, signal) const installed = await installWslBrowserNetworkRelay(distro, bundle, signal) if (!installed) { throw new Error('browser_tunnel_execution_host_unavailable') @@ -140,14 +147,16 @@ export async function launchWslBrowserNetworkRelay( async function startWslBrowserNetworkRelay( distro: string, version: string, + executable: string, signal: AbortSignal ): Promise<{ child?: WslBrowserNetworkRelayChild; code?: number | null }> { - const command = `exec sh "${guestRelayDir(version)}/launch.sh"` + signal.throwIfAborted() + const command = `exec sh "${guestRelayDir(version)}/launch.sh" "$1"` const child = spawnProcess({ program: 'wsl.exe', - args: ['-d', distro, '--exec', 'sh', '-c', command], + args: buildWslExecArgs(distro, ['sh', '-c', command, 'orca-browser-relay', executable]), env: { ...process.env, WSL_UTF8: '1' } - }) as WslBrowserNetworkRelayChild + }) return new Promise((resolve) => { let settled = false let stderr = '' @@ -182,11 +191,12 @@ function installWslBrowserNetworkRelay( bundle: WslBrowserNetworkRelayBundle, signal: AbortSignal ): Promise { + signal.throwIfAborted() const child = spawnProcess({ program: 'wsl.exe', - args: ['-d', distro, '--exec', 'sh', '-s'], + args: buildWslExecArgs(distro, ['sh', '-s']), env: { ...process.env, WSL_UTF8: '1' } - }) as WslBrowserNetworkRelayChild + }) return new Promise((resolve) => { let settled = false const settle = (installed: boolean): void => { diff --git a/src/main/browser/wsl-browser-network-running.test.ts b/src/main/browser/wsl-browser-network-running.test.ts new file mode 100644 index 00000000000..38a43d83db8 --- /dev/null +++ b/src/main/browser/wsl-browser-network-running.test.ts @@ -0,0 +1,63 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { launchWslBrowserNetworkRelay } from './wsl-browser-network-relay-launch' +import { + WSL_BROWSER_NETWORK_RELAY_BUNDLE_NAME, + WSL_BROWSER_NETWORK_RELAY_VERSION_FILE +} from '../../shared/wsl-browser-network-relay-contract' + +const mocks = vi.hoisted(() => ({ running: vi.fn(), prepare: vi.fn(), spawn: vi.fn() })) +vi.mock('../wsl/wsl-bun-runtime', () => ({ + assertWslRuntimeDistroRunning: mocks.running, + createRunningWslRuntimeRunner: vi.fn(), + ensureWslBunRuntime: mocks.prepare +})) +vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: mocks.spawn })) +let directory: string +beforeEach(async () => { + vi.resetAllMocks() + directory = await mkdtemp(join(tmpdir(), 'orca-browser-running-')) + const bundle = join(directory, 'wsl') + await mkdir(bundle) + await writeFile(join(bundle, WSL_BROWSER_NETWORK_RELAY_BUNDLE_NAME), '// fixture') + await writeFile(join(bundle, WSL_BROWSER_NETWORK_RELAY_VERSION_FILE), '1.0.0') + vi.stubEnv('ORCA_RELAY_PATH', directory) + mocks.prepare.mockResolvedValue('/guest/bun') + mocks.running.mockResolvedValue(undefined) +}) +afterEach(async () => { + vi.unstubAllEnvs() + await rm(directory, { recursive: true, force: true }) +}) + +it('does not launch if the distro stopped during runtime preparation', async () => { + mocks.running.mockRejectedValueOnce(new Error('distro stopped')) + await expect( + launchWslBrowserNetworkRelay('Ubuntu', new AbortController().signal) + ).rejects.toThrow('distro stopped') + expect(mocks.prepare).toHaveBeenCalledOnce() + expect(mocks.spawn).not.toHaveBeenCalled() +}) + +it('does not install into a distro that stopped after a stale launch', async () => { + mocks.running.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('distro stopped')) + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn() + }) + mocks.spawn.mockImplementationOnce(() => { + queueMicrotask(() => child.emit('close', 42)) + return child + }) + await expect( + launchWslBrowserNetworkRelay('Ubuntu', new AbortController().signal) + ).rejects.toThrow('distro stopped') + expect(mocks.spawn).toHaveBeenCalledOnce() + expect(mocks.running).toHaveBeenCalledTimes(2) +}) diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 0c2133f320e..d7bd3a20c36 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -42,6 +42,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/browser/browser-route-h3-egress-electron-main.ts', 1], ['main/browser/browser-route-persisted-worker-fixture.ts', 3], ['main/browser/browser-route-tcp-egress-fixture.ts', 1], + // Guest Bun fixture consumes the entire response before checking the returned status. + ['main/wsl/wsl-bun-hook-fixture.ts', 1], // Electron-test rig: the CDP poll cancels its unread body and the version probe consumes // the body through response.json(), so neither leaves an unread undici response. ['main/browser/browser-session-ua-cdp-collector.ts', 2], diff --git a/src/main/wsl/wsl-bun-browser-fixture.ts b/src/main/wsl/wsl-bun-browser-fixture.ts new file mode 100644 index 00000000000..f8b19f4f645 --- /dev/null +++ b/src/main/wsl/wsl-bun-browser-fixture.ts @@ -0,0 +1,95 @@ +import { once } from 'node:events' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { createInterface } from 'node:readline' +import assert from 'node:assert/strict' +import { spawnProcess } from '../../shared/child-process/run-process' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' +import { resolveWslBrowserNetworkExecutionRoute } from '../browser/wsl-browser-network-execution-route' +import { buildWslBrowserNetworkGuestInstallScript } from '../browser/wsl-browser-network-relay-launch' +import { resolveWslExecutablePath } from './wsl-executable-path' +import type { WslSpec } from './wsl-runner' + +export async function verifyWslBunBrowserRelay(options: { + distro: string + home: string + executable: string + run: (spec: WslSpec) => Promise +}): Promise { + const { distro, home, executable, run } = options + const version = 'runtime-qualification' + const bundle = await readFile(join(process.cwd(), 'out/relay/wsl/wsl-browser-network-relay.js')) + await run({ + script: buildWslBrowserNetworkGuestInstallScript(bundle, version), + loginPath: 'none' + }) + const spawnGuest = (args: string[]) => + spawnProcess({ + program: resolveWslExecutablePath(), + args: buildWslExecArgs(distro, ['env', `HOME=${home}`, ...args]), + env: { ORCA_BACKGROUND_LAUNCH: '1', WSL_UTF8: '1' } + }) + const echo = spawnGuest([ + executable, + '-e', + ` + const net = require('node:net'); + const server = net.createServer(socket => { + socket.on('error', error => { if(error.code !== 'ECONNRESET') throw error; }); + socket.pipe(socket); + }); + server.listen(0, '127.0.0.1', () => console.log(server.address().port)); + process.stdin.resume(); + process.stdin.on('end', () => { server.close(); process.stdin.destroy(); }); + ` + ]) + echo.stderr.resume() + const timeout = AbortSignal.timeout(15_000) + const echoClosed = once(echo, 'close', { signal: timeout }) + void echoClosed.catch(() => {}) + const lines = createInterface({ input: echo.stdout }) + try { + const [line] = await once(lines, 'line', { signal: timeout }) + const port = Number(line) + assert(Number.isInteger(port) && port > 0, 'Guest echo server did not report its port') + const relay = spawnGuest([ + 'sh', + `${home}/.orca-wsl/browser-network/bun/${version}/launch.sh`, + executable + ]) + relay.stderr.resume() + const relayClosed = once(relay, 'close', { signal: timeout }) + void relayClosed.catch(() => {}) + const route = await resolveWslBrowserNetworkExecutionRoute( + { + executionHost: { kind: 'wsl', runtimeId: 'test-runtime', revision: 1, distro }, + runtimeId: 'test-runtime', + runtimeRevision: 1 + }, + { launchRelay: async () => relay } + ) + try { + const socket = route.connect({ host: '127.0.0.1', port }) + const received = new Promise((resolve, reject) => { + socket.on('error', reject) + socket.on('data', resolve) + socket.on('connect', () => socket.write(Buffer.from('Windows to WSL Bun browser relay'))) + }) + const bytes = await waitForPromiseWithSignal(received, timeout) + assert.equal(Buffer.from(bytes).toString(), 'Windows to WSL Bun browser relay') + socket.destroy() + } finally { + await route.close() + await relayClosed + } + } finally { + lines.close() + echo.stdin.end() + try { + await echoClosed + } finally { + echo.kill() + } + } +} diff --git a/src/main/wsl/wsl-bun-hook-fixture.ts b/src/main/wsl/wsl-bun-hook-fixture.ts new file mode 100644 index 00000000000..7031526a3cb --- /dev/null +++ b/src/main/wsl/wsl-bun-hook-fixture.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { spawnProcess } from '../../shared/child-process/run-process' +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' +import { AGENT_HOOK_NOTIFICATION_METHOD } from '../../shared/agent-hook-relay' +import { + WSL_HOOK_FS_METHODS, + wslHookRelayEndpointFilePath +} from '../../shared/wsl-hook-relay-contract' +import { buildGuestInstallScript } from '../agent-hooks/wsl-hook-relay-launch' +import { waitForWslRelaySentinel } from '../agent-hooks/wsl-hook-relay-sentinel' +import { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { resolveWslExecutablePath } from './wsl-executable-path' +import type { WslSpec } from './wsl-runner' + +export async function verifyWslBunHookRelay(options: { + distro: string + home: string + executable: string + run: (spec: WslSpec) => Promise +}): Promise { + const { distro, home, executable, run } = options + const version = 'runtime-qualification' + const instance = 'runtime-test' + const token = 'isolated-runtime-test' + const bundle = await readFile(join(process.cwd(), 'out/relay/wsl/wsl-agent-hook-relay.js')) + await run({ script: buildGuestInstallScript(bundle, version), loginPath: 'none' }) + const child = spawnProcess({ + program: resolveWslExecutablePath(), + args: buildWslExecArgs(distro, [ + 'env', + `HOME=${home}`, + 'ORCA_AGENT_HOOK_PORT=57891', + `ORCA_AGENT_HOOK_TOKEN=${token}`, + `ORCA_WSL_HOOK_INSTANCE=${instance}`, + `ORCA_WSL_HOOK_RELAY_VERSION=${version}`, + 'sh', + `${home}/.orca-wsl/hook-relay/bun/${version}/launch.sh`, + executable + ]), + env: { ORCA_BACKGROUND_LAUNCH: '1', WSL_UTF8: '1' } + }) + const closed = once(child, 'close', { signal: AbortSignal.timeout(20_000) }) + void closed.catch(() => {}) + let mux: SshChannelMultiplexer | undefined + try { + mux = new SshChannelMultiplexer(await waitForWslRelaySentinel(child)) + const information = await mux.request(WSL_HOOK_FS_METHODS.home) + assert(information && typeof information === 'object' && 'home' in information) + assert.equal(information.home, home) + const endpoint = await run({ + program: 'cat', + args: [wslHookRelayEndpointFilePath(home, instance)], + loginPath: 'none' + }) + const port = Number(/ORCA_AGENT_HOOK_PORT=['"]?(\d+)/.exec(endpoint)?.[1]) + assert(Number.isInteger(port) && port > 0) + const paneKey = 'runtime-tab:11111111-1111-4111-8111-111111111111' + const received = new Promise>((resolve) => { + mux?.onNotificationByMethod(AGENT_HOOK_NOTIFICATION_METHOD, resolve) + }) + const body = { + paneKey, + tabId: 'runtime-tab', + worktreeId: 'runtime-folder', + env: 'remote', + version: '1', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'WSL Bun hook round trip' } + } + const status = await run({ + program: executable, + args: [ + '-e', + `const response = await fetch('http://127.0.0.1:${port}/hook/claude', { + method: 'POST', headers: {'Content-Type':'application/json','X-Orca-Agent-Hook-Token':${JSON.stringify(token)}}, + body: ${JSON.stringify(JSON.stringify(body))} + }); console.log(response.status); await response.arrayBuffer();` + ], + loginPath: 'none' + }) + assert.equal(status, '204') + const envelope = await Promise.race([ + received, + closed.then(() => { + throw new Error('Hook relay closed before delivering the envelope') + }) + ]) + assert.equal(envelope.paneKey, paneKey) + } finally { + mux?.dispose() + child.stdin.end() + try { + await closed + } finally { + child.kill() + } + } +} diff --git a/src/main/wsl/wsl-bun-relay-launchers.test.ts b/src/main/wsl/wsl-bun-relay-launchers.test.ts new file mode 100644 index 00000000000..901fc3275b5 --- /dev/null +++ b/src/main/wsl/wsl-bun-relay-launchers.test.ts @@ -0,0 +1,60 @@ +import { chmod, 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 { runProcess } from '../../shared/child-process/run-process' +import { buildGuestInstallScript } from '../agent-hooks/wsl-hook-relay-launch' +import { buildWslBrowserNetworkGuestInstallScript } from '../browser/wsl-browser-network-relay-launch' + +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe.skipIf(process.platform === 'win32')('WSL Bun launcher scripts', () => { + it.each([ + { + directory: 'hook-relay', + unavailableCode: 43, + filename: 'wsl-agent-hook-relay.js', + install: buildGuestInstallScript + }, + { + directory: 'browser-network', + unavailableCode: 73, + filename: 'wsl-browser-network-relay.js', + install: buildWslBrowserNetworkGuestInstallScript + } + ])( + 'runs $directory with only the selected runtime, preserving literal paths', + async ({ directory, filename, install, unavailableCode }) => { + const home = await mkdtemp(join(tmpdir(), "orca '$ runtime ")) + roots.push(home) + const version = '1.0.0+test' + const env = { HOME: home, ORCA_WSL_HOOK_RELAY_VERSION: version } + const installed = await runProcess({ + program: '/bin/sh', + args: ['-c', install(Buffer.from('// bundle'), version)], + env + }) + expect(installed.code, installed.stderr).toBe(0) + const executable = join(home, "bun '$ literal") + await writeFile(executable, '#!/bin/sh\nprintf "%s" "$1"\n') + await chmod(executable, 0o700) + const directoryPath = join(home, '.orca-wsl', directory, 'bun', version) + const launched = await runProcess({ + program: '/bin/sh', + args: [join(directoryPath, 'launch.sh'), executable], + env + }) + expect(launched.code, launched.stderr).toBe(0) + expect(launched.stdout).toBe(join(directoryPath, filename)) + const missing = await runProcess({ + program: '/bin/sh', + args: [join(directoryPath, 'launch.sh')], + env + }) + expect(missing.code).toBe(unavailableCode) + } + ) +}) diff --git a/src/main/wsl/wsl-bun-runtime-running.test.ts b/src/main/wsl/wsl-bun-runtime-running.test.ts new file mode 100644 index 00000000000..fb4305ac158 --- /dev/null +++ b/src/main/wsl/wsl-bun-runtime-running.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' +import { runWslProcess } from './wsl-runner' +import { createRunningWslRuntimeRunner } from './wsl-bun-runtime' + +vi.mock('../wsl-running-path-filter', () => ({ filterPathsToRunningWslDistrosAsync: vi.fn() })) +vi.mock('./wsl-runner', () => ({ runWslProcess: vi.fn() })) + +const probe = vi.mocked(filterPathsToRunningWslDistrosAsync) +const run = vi.mocked(runWslProcess) +const command = { program: 'uname', args: ['-m'], loginPath: 'none' } as const + +beforeEach(() => { + probe.mockImplementation(async (paths) => [...paths]) + run.mockResolvedValue({ + code: 0, + stdout: 'x86_64\n', + stderr: '', + timedOut: false, + environmentResolved: true + }) +}) +afterEach(() => vi.resetAllMocks()) + +describe('running WSL runtime preparation', () => { + it('requires a fresh confirmed running verdict before each command', async () => { + const execution = createRunningWslRuntimeRunner('test-distro') + expect(await execution.run(command)).toBe('x86_64') + probe.mockResolvedValueOnce([]) + await expect(execution.run(command)).rejects.toThrow('not running') + expect(probe).toHaveBeenCalledTimes(2) + expect(probe).toHaveBeenLastCalledWith(expect.any(Array), { requireConfirmed: true }) + expect(run).toHaveBeenCalledOnce() + }) + + it('never probes or starts a guest for an already cancelled request', async () => { + const reason = new Error('cancelled by caller') + const execution = createRunningWslRuntimeRunner('test-distro', AbortSignal.abort(reason)) + await expect(execution.run(command)).rejects.toBe(reason) + expect(probe).not.toHaveBeenCalled() + expect(run).not.toHaveBeenCalled() + }) + + it('does not start a guest when cancellation races the running verdict', async () => { + const controller = new AbortController() + const reason = new Error('cancelled during probe') + probe.mockImplementationOnce(async (paths) => { + controller.abort(reason) + return [...paths] + }) + const execution = createRunningWslRuntimeRunner('test-distro', controller.signal) + await expect(execution.run(command)).rejects.toBe(reason) + expect(run).not.toHaveBeenCalled() + }) + + it('rejects a late successful command after cancellation and prevents the next command', async () => { + const controller = new AbortController() + const reason = new Error('cancelled during command') + run.mockImplementationOnce(async () => { + controller.abort(reason) + return { + code: 0, + stdout: 'late success', + stderr: '', + timedOut: false, + environmentResolved: true + } + }) + const execution = createRunningWslRuntimeRunner('test-distro', controller.signal) + await expect(execution.run(command)).rejects.toBe(reason) + await expect(execution.run(command)).rejects.toBe(reason) + expect(run).toHaveBeenCalledOnce() + expect(probe).toHaveBeenCalledOnce() + }) + + it.each([ + { code: 1, timedOut: false }, + { code: 0, timedOut: true } + ])('refuses unsuccessful guest results: %j', async (result) => { + run.mockResolvedValueOnce({ + ...result, + stdout: 'not a valid result', + stderr: 'guest failed', + environmentResolved: true + }) + await expect(createRunningWslRuntimeRunner('test-distro').run(command)).rejects.toThrow( + 'guest failed' + ) + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + distro: 'test-distro', + timeoutMs: 15_000, + maxOutputBytes: 16 * 1024 + }) + ) + }) +}) diff --git a/src/main/wsl/wsl-bun-runtime.test.ts b/src/main/wsl/wsl-bun-runtime.test.ts new file mode 100644 index 00000000000..0ffcf6a70d6 --- /dev/null +++ b/src/main/wsl/wsl-bun-runtime.test.ts @@ -0,0 +1,214 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import type { WslSpec } from './wsl-runner' +import { ensureWslBunRuntime } from './wsl-bun-runtime' + +const fixture = vi.hoisted(() => ({ source: '', hash: '', download: vi.fn() })) +vi.mock('../../shared/orcad-bun-runtime', () => ({ + ORCAD_BUN_RELEASE_ASSETS: { + 'linux-x64-glibc': { + get executableSha256() { + return fixture.hash + } + } + } +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getPath: () => '/unused-cache' }) +})) +vi.mock('../ssh/orcad-bun-runtime-materializer', () => ({ + materializeCachedOrcadBunRuntime: (...args: unknown[]) => fixture.download(...args) +})) +const directories: string[] = [] +afterEach(async () => { + await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) + vi.clearAllMocks() +}) + +async function setup() { + const home = await mkdtemp(join(tmpdir(), "orca wsl '$ ")) + directories.push(home) + fixture.source = join(home, 'downloaded-bun') + const content = 'verified runtime fixture' + fixture.hash = createHash('sha256').update(content).digest('hex') + await writeFile(fixture.source, content) + fixture.download.mockResolvedValue(fixture.source) + const run = async (spec: WslSpec): Promise => { + if (spec.program === 'uname') { + return 'x86_64' + } + if (spec.program === 'wslpath') { + return fixture.source + } + if (spec.script?.startsWith('getconf')) { + return 'glibc 2.31' + } + if (spec.script === undefined) { + throw new Error(`Unexpected program ${spec.program}`) + } + const result = await runProcess({ + program: '/bin/sh', + args: ['-c', spec.script], + env: { HOME: home }, + timeoutMs: 10_000 + }) + if (result.code !== 0) { + throw new Error(result.stderr) + } + return result.stdout.trim() + } + return { run, signal: AbortSignal.timeout(30_000), content } +} + +describe.skipIf(process.platform === 'win32')('WSL runtime publication scripts', () => { + it('publishes verified bytes, cleans staging, and reuses them without another download', async () => { + const setupResult = await setup() + const executable = await ensureWslBunRuntime(setupResult) + expect(await readFile(executable, 'utf8')).toBe(setupResult.content) + expect(await readdir(dirname(executable))).toEqual(['bun']) + expect(await ensureWslBunRuntime(setupResult)).toBe(executable) + expect(fixture.download).toHaveBeenCalledOnce() + }) + it('retains a damaged executable while publishing and reusing a separate verified repair', async () => { + const setupResult = await setup() + const original = await ensureWslBunRuntime(setupResult) + await writeFile(original, 'damaged') + const repaired = await ensureWslBunRuntime(setupResult) + expect(repaired).not.toBe(original) + expect(await readFile(original, 'utf8')).toBe('damaged') + expect(await readFile(repaired, 'utf8')).toBe(setupResult.content) + expect(await ensureWslBunRuntime(setupResult)).toBe(repaired) + expect(fixture.download).toHaveBeenCalledTimes(2) + expect((await readdir(dirname(original))).some((entry) => entry.startsWith('upload-'))).toBe( + false + ) + }) + it.each(['fresh', 'repair'] as const)( + 'keeps every concurrent %s publisher usable and reuses the final runtime', + async (mode) => { + const execution = await setup() + if (mode === 'repair') { + const original = await ensureWslBunRuntime(execution) + await writeFile(original, 'damaged') + } + fixture.download.mockClear() + const probes = Promise.withResolvers() + let arrivals = 0 + const concurrentExecution = { + ...execution, + run: async (spec: WslSpec): Promise => { + const result = await execution.run(spec) + if (result === '__ORCA_BUN_MISSING__') { + if (++arrivals === 4) { + probes.resolve() + } + await probes.promise + } + return result + } + } + const published = await Promise.all( + Array.from({ length: 4 }, () => ensureWslBunRuntime(concurrentExecution)) + ) + expect(fixture.download).toHaveBeenCalledOnce() + for (const executable of published) { + expect(await readFile(executable, 'utf8')).toBe(execution.content) + } + const reused = await ensureWslBunRuntime(execution) + expect(published).toContain(reused) + expect(fixture.download).toHaveBeenCalledOnce() + const directory = join(dirname(fixture.source), '.cache', 'orca', 'runtimes', fixture.hash) + expect((await readdir(directory)).some((entry) => entry.startsWith('upload-'))).toBe(false) + if (mode === 'repair') { + expect(await readFile(join(directory, 'bun'), 'utf8')).toBe('damaged') + } + } + ) + + it('removes a partial copy after failure and retries without exposing it as a runtime', async () => { + const setupResult = await setup() + let partialCopy = true + const execution = { + ...setupResult, + run: async (spec: WslSpec): Promise => { + if (partialCopy && spec.script?.includes("trap 'rm -rf")) { + partialCopy = false + // Fail the real publication script after producing partial bytes in its staging file. + return setupResult.run({ + ...spec, + script: `cp() { printf partial > "$3"; return 1; }\n${spec.script}` + }) + } + return setupResult.run(spec) + } + } + await expect(ensureWslBunRuntime(execution)).rejects.toThrow() + const home = dirname(fixture.source) + const runtimeDirectory = join(home, '.cache', 'orca', 'runtimes', fixture.hash) + expect(await readdir(runtimeDirectory)).toEqual([]) + const executable = await ensureWslBunRuntime(execution) + expect(await readFile(executable, 'utf8')).toBe(setupResult.content) + expect(await readdir(runtimeDirectory)).toEqual(['bun']) + }) + + it.each(['partial-copy', 'published-link', 'unreferenced-repair'] as const)( + 'recovers after the guest shell is killed at %s', + async (boundary) => { + const execution = await setup() + const directory = join(dirname(fixture.source), '.cache', 'orca', 'runtimes', fixture.hash) + const original = join(directory, 'bun') + if (boundary === 'unreferenced-repair') { + await ensureWslBunRuntime(execution) + await writeFile(original, 'damaged') + } + const interruption = + boundary === 'partial-copy' + ? 'cp() { printf partial > "$3"; kill -KILL $$; }' + : boundary === 'published-link' + ? 'ln() { command ln "$@" || return; kill -KILL $$; }' + : 'mv() { command mv "$@" || return; kill -KILL $$; }' + await expect( + ensureWslBunRuntime({ + ...execution, + run: (spec) => + execution.run( + spec.script?.includes("trap 'rm -rf") + ? { ...spec, script: `${interruption}\n${spec.script}` } + : spec + ) + }) + ).rejects.toThrow() + + const interruptedEntries = await readdir(directory) + const stage = interruptedEntries.find((entry) => entry.startsWith('upload-')) + if (!stage) { + throw new Error('Interruption did not leave the expected upload stage') + } + if (boundary === 'partial-copy') { + await expect(readFile(original)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(join(directory, stage, 'bun'), 'utf8')).toBe('partial') + } else if (boundary === 'published-link') { + expect(await readFile(original, 'utf8')).toBe(execution.content) + } else { + expect(await readFile(original, 'utf8')).toBe('damaged') + await expect(readFile(join(directory, '.current-repair'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + } + + const recovered = await ensureWslBunRuntime(execution) + expect(await readFile(recovered, 'utf8')).toBe(execution.content) + expect(await ensureWslBunRuntime(execution)).toBe(recovered) + // A retry cannot reclaim another publisher's staging without proving its owner exited. + expect(await readdir(directory)).toEqual(expect.arrayContaining(interruptedEntries)) + if (boundary === 'unreferenced-repair') { + expect(recovered).not.toBe(original) + expect(await readFile(original, 'utf8')).toBe('damaged') + } + } + ) +}) diff --git a/src/main/wsl/wsl-bun-runtime.ts b/src/main/wsl/wsl-bun-runtime.ts new file mode 100644 index 00000000000..be3215493ea --- /dev/null +++ b/src/main/wsl/wsl-bun-runtime.ts @@ -0,0 +1,144 @@ +import { randomBytes } from 'node:crypto' +import { join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { ORCAD_BUN_RELEASE_ASSETS, type OrcadBunTarget } from '../../shared/orcad-bun-runtime' +import { toWindowsWslUncPath } from '../../shared/wsl-paths' +import { materializeCachedOrcadBunRuntime } from '../ssh/orcad-bun-runtime-materializer' +import { parseOrcadLinuxLibc } from '../ssh/orcad-deployment-target' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { shellEscape } from '../ssh/ssh-connection-utils' +import { + parseRelayBunRuntimeResult, + probeRelayBunRuntimeCommand, + promoteRelayBunRuntimeCommand +} from '../ssh/ssh-relay-bun-runtime-commands' +import { runWslProcess, type WslSpec } from './wsl-runner' +import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' + +const PREPARATION_TIMEOUT_MS = 180_000 +const downloads = new Map>() + +export async function assertWslRuntimeDistroRunning( + distro: string, + signal: AbortSignal +): Promise { + signal.throwIfAborted() + const running = await waitForPromiseWithSignal( + filterPathsToRunningWslDistrosAsync([toWindowsWslUncPath('/', distro)], { + requireConfirmed: true + }), + signal + ) + if (running.length === 0) { + throw new Error(`WSL distro ${distro} is not running. Start it to prepare its runtime.`) + } + signal.throwIfAborted() +} + +/** Refuse guest commands without a confirmed running verdict; WSL has no atomic check-and-exec. */ +export function createRunningWslRuntimeRunner( + distro: string, + callerSignal?: AbortSignal +): { + run: (spec: WslSpec) => Promise + signal: AbortSignal +} { + const deadline = Date.now() + PREPARATION_TIMEOUT_MS + const timeout = AbortSignal.timeout(PREPARATION_TIMEOUT_MS) + const signal = callerSignal ? AbortSignal.any([callerSignal, timeout]) : timeout + const run = async (spec: WslSpec): Promise => { + await assertWslRuntimeDistroRunning(distro, signal) + const result = await runWslProcess({ + ...spec, + distro, + timeoutMs: Math.max(1, Math.min(15_000, deadline - Date.now())), + maxOutputBytes: 16 * 1024 + }) + signal.throwIfAborted() + if (result.code !== 0 || result.timedOut) { + throw new Error(`WSL runtime setup failed: ${result.stderr.trim() || 'command failed'}`) + } + return result.stdout.trim() + } + return { run, signal } +} + +export async function ensureWslBunRuntime( + execution: ReturnType +): Promise { + const { run, signal } = execution + const arch = await run({ program: 'uname', args: ['-m'], loginPath: 'none' }) + if (arch !== 'x86_64' && arch !== 'aarch64' && arch !== 'arm64') { + throw new Error(`Unsupported WSL runtime architecture: ${arch}`) + } + const libc = parseOrcadLinuxLibc( + await run({ + script: + 'getconf GNU_LIBC_VERSION 2>/dev/null || ldd --version 2>&1 || ' + + 'for loader in /lib/ld-musl-*.so.1; do [ ! -e "$loader" ] || { echo musl; break; }; done', + loginPath: 'none' + }) + ) + const platform = `linux-${arch === 'x86_64' ? 'x64' : 'arm64'}` as const + const target = `${platform}-${libc}` as const + const host = getRemoteHostPlatform(platform) + const expectedHash = ORCAD_BUN_RELEASE_ASSETS[target].executableSha256 + const home = await run({ script: 'printf %s "$HOME"', loginPath: 'none' }) + if (!home.startsWith('/')) { + throw new Error('WSL did not provide an absolute home directory.') + } + const directory = `${home}/.cache/orca/runtimes/${expectedHash}` + const executable = `${directory}/bun` + const cached = parseRelayBunRuntimeResult( + await run({ + script: probeRelayBunRuntimeCommand(host, executable, expectedHash), + loginPath: 'none' + }) + ) + if (cached) { + return cached + } + let download = downloads.get(target) + if (!download) { + download = materializeCachedOrcadBunRuntime( + target, + join(getAppEnvironment().getPath('userData'), 'orcad-artifacts'), + { signal: AbortSignal.timeout(PREPARATION_TIMEOUT_MS) } + ).finally(() => downloads.delete(target)) + downloads.set(target, download) + } + const localRuntime = await waitForPromiseWithSignal(download, signal) + const source = await run({ + program: 'wslpath', + args: ['-a', '-u', localRuntime], + loginPath: 'none' + }) + const token = randomBytes(12).toString('hex') + const stage = `${directory}/upload-${token}` + const uploaded = `${stage}/bun` + const published = parseRelayBunRuntimeResult( + await run({ + script: [ + 'umask 077', + `mkdir -p ${shellEscape(directory)} || exit 1`, + `mkdir ${shellEscape(stage)} || exit 1`, + `stage=${shellEscape(stage)}`, + `trap 'rm -rf -- "$stage"' EXIT`, + `cp -- ${shellEscape(source)} ${shellEscape(uploaded)} || exit 1`, + promoteRelayBunRuntimeCommand({ + host, + source: uploaded, + executable, + expectedHash, + repairToken: token + }) + ].join('\n'), + loginPath: 'none' + }) + ) + if (!published) { + throw new Error('WSL did not publish the verified Bun runtime.') + } + return published +} diff --git a/src/main/wsl/wsl-bun-runtime.win32.integration.test.ts b/src/main/wsl/wsl-bun-runtime.win32.integration.test.ts new file mode 100644 index 00000000000..5ea8b0fd856 --- /dev/null +++ b/src/main/wsl/wsl-bun-runtime.win32.integration.test.ts @@ -0,0 +1,148 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { + getAppEnvironment, + hasAppEnvironment, + setAppEnvironment +} from '../../shared/app-environment' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { getMainHttpClient, setMainHttpClient } from '../network/http-client' +import { shellEscape } from '../ssh/ssh-connection-utils' +import { createRunningWslRuntimeRunner, ensureWslBunRuntime } from './wsl-bun-runtime' +import type { WslSpec } from './wsl-runner' +import { verifyWslBunBrowserRelay } from './wsl-bun-browser-fixture' +import { verifyWslBunHookRelay } from './wsl-bun-hook-fixture' + +const distro = process.env.ORCA_TEST_WSL_DISTRO + +it.skipIf(process.platform !== 'win32' || !distro)( + 'provisions and repairs Bun offline, then carries browser and hook traffic through real WSL', + async () => { + if (!distro) { + throw new Error('ORCA_TEST_WSL_DISTRO is required') + } + const hostRoot = await mkdtemp(join(tmpdir(), 'orca-wsl-bun-')) + const previousEnvironment = hasAppEnvironment() ? getAppEnvironment() : null + const previousHttp = getMainHttpClient() + setAppEnvironment({ + getPath: () => hostRoot, + getAppPath: () => hostRoot, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + let guestRoot: string | undefined + try { + const execution = createRunningWslRuntimeRunner(distro) + guestRoot = await execution.run({ + program: 'mktemp', + args: ['-d', '/tmp/orca-wsl-bun-XXXXXX'], + loginPath: 'none' + }) + if (!/^\/tmp\/orca-wsl-bun-[A-Za-z0-9]+$/.test(guestRoot)) { + throw new Error(`Unexpected guest test directory: ${guestRoot}`) + } + const home = `${guestRoot}/home with '$ spaces` + await execution.run({ program: 'mkdir', args: ['-p', home], loginPath: 'none' }) + const run = (spec: WslSpec): Promise => + execution.run( + spec.script !== undefined + ? { ...spec, script: `export HOME=${shellEscape(home)}\n${spec.script}` } + : { + ...spec, + program: 'env', + args: [`HOME=${home}`, spec.program, ...(spec.args ?? [])] + } + ) + const isolated = { run, signal: execution.signal } + let interrupted = false + await expect( + ensureWslBunRuntime({ + ...isolated, + run: (spec) => { + if (spec.script?.includes("trap 'rm -rf")) { + interrupted = true + return run({ + ...spec, + script: spec.script.replace(/(\ncp --[^\n]+\n)/, '$1exit 23\n') + }) + } + return run(spec) + } + }) + ).rejects.toThrow('WSL runtime setup failed') + expect(interrupted).toBe(true) + expect( + await run({ + script: `find ${shellEscape(`${home}/.cache/orca/runtimes`)} -name 'upload-*' -print`, + loginPath: 'none' + }) + ).toBe('') + const lostAcknowledgement = new Error('Published runtime acknowledgement lost') + await expect( + ensureWslBunRuntime({ + ...isolated, + run: async (spec) => { + const result = await run(spec) + if (spec.script?.includes("trap 'rm -rf")) { + throw lostAcknowledgement + } + return result + } + }) + ).rejects.toBe(lostAcknowledgement) + const executable = await ensureWslBunRuntime(isolated) + expect(executable.startsWith(`${home}/.cache/orca/runtimes/`)).toBe(true) + expect(await run({ program: executable, args: ['--version'], loginPath: 'none' })).toBe( + ORCAD_BUN_VERSION + ) + setMainHttpClient({ + fetch: async () => { + throw new Error('Cached runtime must not access the network') + }, + proxySession: () => null + }) + expect(await ensureWslBunRuntime(isolated)).toBe(executable) + await run({ script: `printf damaged > ${shellEscape(executable)}`, loginPath: 'none' }) + const repaired = await ensureWslBunRuntime(isolated) + expect(repaired).not.toBe(executable) + expect(await run({ program: repaired, args: ['--version'], loginPath: 'none' })).toBe( + ORCAD_BUN_VERSION + ) + expect(await ensureWslBunRuntime(isolated)).toBe(repaired) + expect(await run({ program: 'cat', args: [executable], loginPath: 'none' })).toBe('damaged') + expect( + await run({ + script: `find ${shellEscape(`${home}/.cache/orca/runtimes`)} -name 'upload-*' -print`, + loginPath: 'none' + }) + ).toBe('') + await verifyWslBunBrowserRelay({ distro, home, executable: repaired, run }) + await verifyWslBunHookRelay({ distro, home, executable: repaired, run }) + const aborted = createRunningWslRuntimeRunner(distro, AbortSignal.abort()) + await expect(ensureWslBunRuntime(aborted)).rejects.toMatchObject({ name: 'AbortError' }) + const absent = createRunningWslRuntimeRunner('orca-test-not-an-installed-distro') + await expect(absent.run({ program: 'true', loginPath: 'none' })).rejects.toThrow( + 'not running' + ) + } finally { + setMainHttpClient(previousHttp) + if (previousEnvironment) { + setAppEnvironment(previousEnvironment) + } + if (guestRoot && /^\/tmp\/orca-wsl-bun-[A-Za-z0-9]+$/.test(guestRoot)) { + await createRunningWslRuntimeRunner(distro).run({ + program: 'rm', + args: ['-rf', '--', guestRoot], + loginPath: 'none' + }) + } + await rm(hostRoot, { recursive: true, force: true }) + } + }, + 240_000 +) diff --git a/src/relay/wsl-browser-network-relay.ts b/src/relay/wsl-browser-network-relay.ts index 3bcef6f703e..a578020fb02 100644 --- a/src/relay/wsl-browser-network-relay.ts +++ b/src/relay/wsl-browser-network-relay.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env node import { connect } from 'node:net' import { BrowserNetworkTunnelSession } from '../main/browser/browser-network-tunnel-session' import { @@ -14,6 +13,7 @@ function main(): void { return } shuttingDown = true + process.stdin.destroy() decoder.close() writer.close() session.close() diff --git a/src/shared/wsl-browser-network-relay-contract.ts b/src/shared/wsl-browser-network-relay-contract.ts index 9df72d8eff9..f720a895c5e 100644 --- a/src/shared/wsl-browser-network-relay-contract.ts +++ b/src/shared/wsl-browser-network-relay-contract.ts @@ -3,4 +3,4 @@ export const WSL_BROWSER_NETWORK_RELAY_VERSION_FILE = '.browser-network-version' export const WSL_BROWSER_NETWORK_RELAY_DIR = '.orca-wsl/browser-network' export const WSL_BROWSER_NETWORK_RELAY_SENTINEL = 'ORCA-WSL-BROWSER-NETWORK v1 READY\n' export const WSL_BROWSER_NETWORK_RELAY_STALE_EXIT_CODE = 72 -export const WSL_BROWSER_NETWORK_RELAY_NO_NODE_EXIT_CODE = 73 +export const WSL_BROWSER_NETWORK_RELAY_RUNTIME_UNAVAILABLE_EXIT_CODE = 73 diff --git a/src/shared/wsl-hook-relay-contract.ts b/src/shared/wsl-hook-relay-contract.ts index 87f099e6e0d..79c33dfe05a 100644 --- a/src/shared/wsl-hook-relay-contract.ts +++ b/src/shared/wsl-hook-relay-contract.ts @@ -2,7 +2,7 @@ // agent-hook relay. Both sides derive paths/methods from here so the guest // process and the host manager can never drift on where the relay lives, // which JSON-RPC methods the fs bridge speaks, or which exit codes signal -// "reinstall me" vs "no usable node". +// "reinstall me" vs "runtime unavailable". // See docs/agent-status-over-wsl.md (STA-1515). /** Guest-side install dir for the relay bundle, relative to `$HOME`. */ @@ -26,7 +26,7 @@ export const WSL_HOOK_RELAY_INSTANCE_ENV = 'ORCA_WSL_HOOK_INSTANCE' /** Launch-script exit codes. 42 mirrors the SSH relay's handshake-mismatch * convention: the host reinstalls the bundle and relaunches once. */ export const WSL_HOOK_RELAY_STALE_EXIT_CODE = 42 -export const WSL_HOOK_RELAY_NO_NODE_EXIT_CODE = 43 +export const WSL_HOOK_RELAY_RUNTIME_UNAVAILABLE_EXIT_CODE = 43 /** JSON-RPC methods for the relay's home-scoped fs bridge. The host runs the * unchanged SSH remote hook installers against these via an SFTP-shaped