mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
feat: run WSL helpers with a verified distro-local Bun runtime
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
@@ -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<string[]>
|
||||
isDistroRunning: typeof isWslDistroRunning
|
||||
prepareRuntime: (distro: string) => Promise<string>
|
||||
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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<string>
|
||||
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<void>
|
||||
}): Promise<void> {
|
||||
const { distro, env, bundleJsPath, version, io } = options
|
||||
if (options.isDisposed()) {
|
||||
return
|
||||
}
|
||||
const requireRunning = async (): Promise<boolean> => {
|
||||
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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string>((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<boolean>((resolve) => (resolveProbe = resolve)))
|
||||
const isDistroRunning = vi
|
||||
.fn(async () => true)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof success>) => 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<typeof success>) => 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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, { value: OpenCodeWslRuntime; expires: number }>()
|
||||
const downloads = new Map<OrcadBunTarget, Promise<string>>()
|
||||
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<OpenCodeWslRuntime> {
|
||||
const deadline = Date.now() + PREPARATION_TIMEOUT_MS
|
||||
const signal = AbortSignal.timeout(PREPARATION_TIMEOUT_MS)
|
||||
const run = async (spec: WslSpec): Promise<string> => {
|
||||
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<OpenCodeWslRuntime> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<Uint8Array>((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<void>((resolve) => server.close(() => resolve()))
|
||||
expect(serverErrors).toEqual([])
|
||||
}
|
||||
},
|
||||
15_000
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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<boolean> {
|
||||
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 => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -42,6 +42,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
|
||||
['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],
|
||||
|
||||
@@ -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<string>
|
||||
}): Promise<void> {
|
||||
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<Uint8Array>((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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string>
|
||||
}): Promise<void> {
|
||||
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<Record<string, unknown>>((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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<string> => {
|
||||
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<void>()
|
||||
let arrivals = 0
|
||||
const concurrentExecution = {
|
||||
...execution,
|
||||
run: async (spec: WslSpec): Promise<string> => {
|
||||
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<string> => {
|
||||
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')
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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<OrcadBunTarget, Promise<string>>()
|
||||
|
||||
export async function assertWslRuntimeDistroRunning(
|
||||
distro: string,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
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<string>
|
||||
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<string> => {
|
||||
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<typeof createRunningWslRuntimeRunner>
|
||||
): Promise<string> {
|
||||
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
|
||||
}
|
||||
@@ -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<string> =>
|
||||
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
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user