diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 91b1f55b0b0..0508d3e459c 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -8,6 +8,7 @@ import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode import { mimoCodeHookService } from '../../../mimo/hook-service' import { agentHookServer } from '../../../agent-hooks/server' import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' +import { wslInteropHealthMonitor } from '../../../wsl/wsl-interop-health' import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' import { prependOrcaCliDirToChildPath } from '../../../cli/orca-cli-child-path' import { getManagedWslCliDir, getWslCliCommandName } from '../../../cli/wsl-managed-cli' @@ -266,6 +267,7 @@ export function buildPtyHostEnv( delete baseEnv.ORCA_WSL_CLI_DIR // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. if (opts.isWsl) { + wslInteropHealthMonitor.noteActivity(opts.wslDistro ?? null) baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath // Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim. baseEnv.ORCA_CLI_COMMAND = getWslCliCommandName(opts.isPackaged) diff --git a/src/main/wsl-running-distro-cache.ts b/src/main/wsl-running-distro-cache.ts index 07a55a47bcd..7d2059b2f20 100644 --- a/src/main/wsl-running-distro-cache.ts +++ b/src/main/wsl-running-distro-cache.ts @@ -10,6 +10,32 @@ let retryAfterMs = 0 let failureStreak = 0 type RunningDistroObservation = { distros: string[]; confirmed: boolean } let inFlightProbe: Promise | null = null +type RunningDistroStopListener = (stopped: string[], running: string[]) => void +const stopListeners = new Set() + +/** Notified when a confirmed probe no longer lists a distro the previous one did. */ +export function onRunningWslDistrosStopped(listener: RunningDistroStopListener): () => void { + stopListeners.add(listener) + return () => stopListeners.delete(listener) +} + +function notifyStopped(previous: string[] | null, running: string[]): void { + if (!previous || stopListeners.size === 0) { + return + } + const current = new Set(running.map((distro) => distro.toLowerCase())) + const stopped = previous.filter((distro) => !current.has(distro.toLowerCase())) + if (stopped.length === 0) { + return + } + for (const listener of stopListeners) { + try { + listener(stopped, running) + } catch (error) { + console.warn('[wsl] running-distro stop listener failed', error) + } + } +} function armRetryAfterFailure(): void { const now = Date.now() @@ -51,9 +77,11 @@ function observeRunningWslDistros( } const result = probe() .then((distros) => { + const previous = cache cache = distros retryAfterMs = 0 failureStreak = 0 + notifyStopped(previous, distros) return { distros, confirmed: true } }) .catch((error: unknown) => { diff --git a/src/main/wsl-running-distro-stop.test.ts b/src/main/wsl-running-distro-stop.test.ts new file mode 100644 index 00000000000..a694070e937 --- /dev/null +++ b/src/main/wsl-running-distro-stop.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + _resetRunningWslDistroCacheForTests, + onRunningWslDistrosStopped, + resolveRunningWslDistros +} from './wsl-running-distro-cache' + +describe('onRunningWslDistrosStopped', () => { + afterEach(() => { + _resetRunningWslDistroCacheForTests() + vi.restoreAllMocks() + }) + + it('reports distros a confirmed probe no longer lists', async () => { + const listener = vi.fn() + const unsubscribe = onRunningWslDistrosStopped(listener) + await resolveRunningWslDistros(async () => ['Ubuntu', 'docker-desktop']) + expect(listener).not.toHaveBeenCalled() + await resolveRunningWslDistros(async () => ['ubuntu']) + expect(listener).toHaveBeenCalledWith(['docker-desktop'], ['ubuntu']) + unsubscribe() + }) + + it('ignores failed probes', async () => { + const listener = vi.fn() + const unsubscribe = onRunningWslDistrosStopped(listener) + await resolveRunningWslDistros(async () => ['Ubuntu']) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await resolveRunningWslDistros(async () => { + throw new Error('wsl.exe wedged') + }) + expect(listener).not.toHaveBeenCalled() + unsubscribe() + }) +}) diff --git a/src/main/wsl/wsl-interop-health.test.ts b/src/main/wsl/wsl-interop-health.test.ts new file mode 100644 index 00000000000..acdbf3f8ce2 --- /dev/null +++ b/src/main/wsl/wsl-interop-health.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../wsl', () => ({ + listRunningWslDistrosAsync: vi.fn(), + listWslDistrosAsync: vi.fn() +})) +vi.mock('./wsl-runner', () => ({ runWslProcess: vi.fn() })) + +import { + ACTIVITY_CHECK_DELAY_MS, + ACTIVITY_CHECK_INTERVAL_MS, + REPAIR_FAILURE_BACKOFF_MS, + STOP_CHECK_FLOOR_MS, + WslInteropHealthMonitor, + type WslInteropHealthDeps +} from './wsl-interop-health' +import { WSL_INTEROP_PROBE_SCRIPT, WSL_INTEROP_REPAIR_SCRIPT } from './wsl-interop-registration' + +const PRESENT = 'orca-wsl-interop:present\n' +const MISSING = 'orca-wsl-interop:missing\norca-wsl-interop:conf\n' +const MISSING_DISABLED = `${MISSING}[interop]\nenabled=false\n` + +function harness(overrides: Partial = {}) { + let now = 1_000_000 + const timers: { fn: () => void; delayMs: number }[] = [] + let stopListener: ((stopped: string[], running: string[]) => void) | null = null + const deps: WslInteropHealthDeps = { + platform: () => 'win32', + listRunningDistros: vi.fn(async () => ['Ubuntu']), + resolveDefaultDistro: vi.fn(async () => 'Ubuntu'), + runScript: vi.fn(async () => ({ code: 0, stdout: PRESENT })), + subscribeStops: (listener) => { + stopListener = listener + return () => { + stopListener = null + } + }, + now: () => now, + schedule: (fn, delayMs) => { + timers.push({ fn, delayMs }) + }, + log: vi.fn(), + ...overrides + } + const monitor = new WslInteropHealthMonitor(deps) + return { + deps, + monitor, + timers, + advance: (ms: number) => { + now += ms + }, + runTimers: () => { + for (const timer of timers.splice(0)) { + timer.fn() + } + }, + emitStop: (stopped: string[], running: string[]) => stopListener?.(stopped, running) + } +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +describe('WslInteropHealthMonitor.check', () => { + it('reports intact without repairing', async () => { + const h = harness() + await expect(h.monitor.check('Ubuntu', { confirmRunning: true })).resolves.toBe('intact') + expect(h.deps.runScript).toHaveBeenCalledTimes(1) + expect(h.deps.runScript).toHaveBeenCalledWith('Ubuntu', WSL_INTEROP_PROBE_SCRIPT) + }) + + it('repairs as root when the entry is missing', async () => { + const runScript = vi + .fn() + .mockResolvedValueOnce({ code: 0, stdout: MISSING }) + .mockResolvedValueOnce({ code: 0, stdout: 'orca-wsl-interop:repaired\n' }) + const h = harness({ runScript }) + await expect(h.monitor.check('Ubuntu', { confirmRunning: true })).resolves.toBe('repaired') + expect(runScript).toHaveBeenLastCalledWith('Ubuntu', WSL_INTEROP_REPAIR_SCRIPT, 'root') + expect(h.deps.log).toHaveBeenCalledWith(expect.stringContaining('wsl --update')) + }) + + it('respects interop disabled in wsl.conf', async () => { + const runScript = vi.fn(async () => ({ code: 0, stdout: MISSING_DISABLED })) + const h = harness({ runScript }) + await expect(h.monitor.check('Ubuntu', { confirmRunning: true })).resolves.toBe( + 'interop-disabled' + ) + expect(runScript).toHaveBeenCalledTimes(1) + }) + + it('never runs inside a distro that is not running', async () => { + const h = harness({ listRunningDistros: vi.fn(async () => ['Debian']) }) + await expect(h.monitor.check('Ubuntu', { confirmRunning: true })).resolves.toBe('not-running') + expect(h.deps.runScript).not.toHaveBeenCalled() + }) + + it('treats an unconfirmed running list as not running', async () => { + const h = harness({ + listRunningDistros: vi.fn(async () => { + throw new Error('unavailable') + }) + }) + await expect(h.monitor.check('Ubuntu', { confirmRunning: true })).resolves.toBe('not-running') + expect(h.deps.runScript).not.toHaveBeenCalled() + }) + + it('is single-flight per distro', async () => { + let release: (value: { code: number; stdout: string }) => void = () => undefined + const runScript = vi.fn( + () => + new Promise<{ code: number; stdout: string }>((resolve) => { + release = resolve + }) + ) + const h = harness({ runScript }) + const first = h.monitor.check('Ubuntu', { confirmRunning: false }) + const second = h.monitor.check('ubuntu', { confirmRunning: false }) + expect(second).toBe(first) + await flush() + release({ code: 0, stdout: PRESENT }) + await expect(first).resolves.toBe('intact') + expect(runScript).toHaveBeenCalledTimes(1) + }) + + it('backs off after a failed repair', async () => { + const runScript = vi.fn(async (_distro: string, script: string) => + script === WSL_INTEROP_PROBE_SCRIPT ? { code: 0, stdout: MISSING } : { code: 1, stdout: '' } + ) + const h = harness({ runScript }) + await expect(h.monitor.check('Ubuntu', { confirmRunning: false })).resolves.toBe( + 'repair-failed' + ) + await expect(h.monitor.check('Ubuntu', { confirmRunning: false })).resolves.toBe( + 'repair-failed' + ) + expect(runScript).toHaveBeenCalledTimes(3) + h.advance(REPAIR_FAILURE_BACKOFF_MS) + await h.monitor.check('Ubuntu', { confirmRunning: false }) + expect(runScript).toHaveBeenCalledTimes(5) + }) + + it('reports unknown when the probe throws', async () => { + const h = harness({ + runScript: vi.fn(async () => { + throw new Error('timeout') + }) + }) + await expect(h.monitor.check('Ubuntu', { confirmRunning: false })).resolves.toBe('unknown') + }) +}) + +describe('WslInteropHealthMonitor triggers', () => { + it('does nothing off Windows', async () => { + const h = harness({ platform: () => 'darwin' }) + h.monitor.noteActivity('Ubuntu') + await flush() + expect(h.timers).toHaveLength(0) + }) + + it('checks once after a delay and throttles repeated activity', async () => { + const h = harness() + h.monitor.noteActivity(null) + h.monitor.noteActivity('Ubuntu') + await flush() + expect(h.timers).toEqual([{ fn: expect.any(Function), delayMs: ACTIVITY_CHECK_DELAY_MS }]) + expect(h.deps.runScript).not.toHaveBeenCalled() + h.runTimers() + await flush() + expect(h.deps.listRunningDistros).toHaveBeenCalledTimes(1) + expect(h.deps.runScript).toHaveBeenCalledTimes(1) + + h.monitor.noteActivity('Ubuntu') + await flush() + expect(h.timers).toHaveLength(0) + h.advance(ACTIVITY_CHECK_INTERVAL_MS) + h.monitor.noteActivity('Ubuntu') + await flush() + expect(h.timers).toHaveLength(1) + }) + + it('re-checks used distros that are still running when another distro stops', async () => { + const h = harness() + h.monitor.noteActivity('Ubuntu') + await flush() + h.runTimers() + await flush() + expect(h.deps.runScript).toHaveBeenCalledTimes(1) + h.advance(10_000) + h.emitStop(['docker-desktop'], ['Ubuntu']) + await flush() + expect(h.deps.runScript).toHaveBeenCalledTimes(2) + // The confirmed list that reported the stop already proves Ubuntu is up. + expect(h.deps.listRunningDistros).toHaveBeenCalledTimes(1) + h.advance(10_000) + h.emitStop(['Ubuntu'], []) + await flush() + expect(h.deps.runScript).toHaveBeenCalledTimes(2) + }) + + it('bounds probes under bursts of activity and stop events', async () => { + const h = harness() + for (let i = 0; i < 50; i++) { + h.monitor.noteActivity('Ubuntu') + } + await flush() + h.runTimers() + await flush() + for (let elapsed = 0; elapsed < ACTIVITY_CHECK_INTERVAL_MS - 1_000; elapsed += 1_000) { + h.advance(1_000) + h.monitor.noteActivity('Ubuntu') + await flush() + h.runTimers() + await flush() + } + expect(h.deps.runScript).toHaveBeenCalledTimes(1) + + // Stop-triggered checks are rate-floored and skip distros the list no longer shows. + h.emitStop(['docker-desktop'], ['Ubuntu']) + await flush() + expect(h.deps.runScript).toHaveBeenCalledTimes(2) + h.advance(STOP_CHECK_FLOOR_MS - 1) + h.emitStop(['Debian'], ['Ubuntu']) + h.emitStop(['Debian'], ['Ubuntu']) + await flush() + expect(h.deps.runScript).toHaveBeenCalledTimes(2) + h.advance(1) + h.emitStop(['Ubuntu'], []) + await flush() + expect(h.deps.runScript).toHaveBeenCalledTimes(2) + }) + + it('treats an entry restored between probe and repair as intact', async () => { + const h = harness({ + runScript: vi.fn(async (_distro: string, script: string) => ({ + code: 0, + stdout: script === WSL_INTEROP_PROBE_SCRIPT ? MISSING : PRESENT + })) + }) + await expect(h.monitor.check('Ubuntu', { confirmRunning: false })).resolves.toBe('intact') + expect(h.deps.log).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/wsl/wsl-interop-health.ts b/src/main/wsl/wsl-interop-health.ts new file mode 100644 index 00000000000..4ec0370d697 --- /dev/null +++ b/src/main/wsl/wsl-interop-health.ts @@ -0,0 +1,220 @@ +import { listRunningWslDistrosAsync, listWslDistrosAsync } from '../wsl' +import { onRunningWslDistrosStopped } from '../wsl-running-distro-cache' +import { runWslProcess } from './wsl-runner' +import { + parseWslInteropProbeOutput, + parseWslInteropRepairOutput, + WSL_INTEROP_PROBE_SCRIPT, + WSL_INTEROP_REPAIR_SCRIPT +} from './wsl-interop-registration' + +export type WslInteropCheckOutcome = + | 'intact' + | 'repaired' + | 'interop-disabled' + | 'unsupported' + | 'repair-failed' + | 'not-running' + | 'unknown' + +export type WslInteropHealthDeps = { + platform(): NodeJS.Platform + listRunningDistros(): Promise + resolveDefaultDistro(): Promise + runScript( + distro: string, + script: string, + user?: 'root' + ): Promise<{ code: number | null; stdout: string }> + subscribeStops(listener: (stopped: string[], running: string[]) => void): () => void + now(): number + schedule(fn: () => void, delayMs: number): void + log(message: string): void +} + +// Why delayed: the spawn that reported activity is what boots the distro, and the check must never be the one that does. +export const ACTIVITY_CHECK_DELAY_MS = 5_000 +export const ACTIVITY_CHECK_INTERVAL_MS = 60_000 +// A stop-triggered check bypasses the activity interval but not this floor. +export const STOP_CHECK_FLOOR_MS = 5_000 +export const REPAIR_FAILURE_BACKOFF_MS = 10 * 60_000 +const PROBE_TIMEOUT_MS = 10_000 + +type DistroHealth = { + name: string + lastCheckAt: number + repairFailedAt: number + scheduled: boolean + inFlight: Promise | null +} + +/** + * Detects and restores a wiped `WSLInterop` entry in distros Orca is using. + * + * Why automatic: the missing entry is a WSL bug state, not a user choice; the + * repair re-registers exactly what WSL's init registers at boot, it is + * idempotent, and a prompt would ask users to approve something they cannot + * diagnose. `[interop] enabled=false` in the distro's wsl.conf is respected. + */ +export class WslInteropHealthMonitor { + private readonly distros = new Map() + private unsubscribe: (() => void) | null = null + + constructor(private readonly deps: WslInteropHealthDeps) {} + + /** A WSL terminal is starting in `distro` (null: the default distro). */ + noteActivity(distro: string | null): void { + if (this.deps.platform() !== 'win32') { + return + } + this.unsubscribe ??= this.deps.subscribeStops((stopped, running) => + this.onDistrosStopped(stopped, running) + ) + void this.resolveName(distro).then((name) => { + if (!name) { + return + } + const health = this.healthFor(name) + if (health.scheduled || this.deps.now() - health.lastCheckAt < ACTIVITY_CHECK_INTERVAL_MS) { + return + } + health.scheduled = true + this.deps.schedule(() => { + health.scheduled = false + void this.check(name, { confirmRunning: true }) + }, ACTIVITY_CHECK_DELAY_MS) + }) + } + + /** A stopping systemd distro is exactly when the wipe happens; re-check the ones still up. */ + private onDistrosStopped(_stopped: string[], running: string[]): void { + const runningKeys = new Set(running.map((name) => name.toLowerCase())) + for (const [key, health] of this.distros) { + if (runningKeys.has(key) && this.deps.now() - health.lastCheckAt >= STOP_CHECK_FLOOR_MS) { + // The confirmed list that reported the stop also proves this distro is up. + void this.check(health.name, { confirmRunning: false }) + } + } + } + + check(distro: string, options: { confirmRunning: boolean }): Promise { + const health = this.healthFor(distro) + if (health.inFlight) { + return health.inFlight + } + health.lastCheckAt = this.deps.now() + const run = this.runCheck(health, options.confirmRunning) + .catch((error: unknown) => { + this.deps.log(`[wsl] interop check failed for ${distro}: ${String(error)}`) + return 'unknown' as const + }) + .finally(() => { + if (health.inFlight === run) { + health.inFlight = null + } + }) + health.inFlight = run + return run + } + + private async runCheck( + health: DistroHealth, + confirmRunning: boolean + ): Promise { + const distro = health.name + if (confirmRunning && !(await this.isRunning(distro))) { + return 'not-running' + } + const probe = await this.deps.runScript(distro, WSL_INTEROP_PROBE_SCRIPT) + const result = parseWslInteropProbeOutput(probe.stdout) + if (result.state === 'present') { + return 'intact' + } + if (result.state === 'unmounted') { + return 'unsupported' + } + if (result.state === 'unknown') { + return 'unknown' + } + if (result.interopDisabled) { + return 'interop-disabled' + } + if (this.deps.now() - health.repairFailedAt < REPAIR_FAILURE_BACKOFF_MS) { + return 'repair-failed' + } + const repair = await this.deps.runScript(distro, WSL_INTEROP_REPAIR_SCRIPT, 'root') + const repaired = repair.code === 0 ? parseWslInteropRepairOutput(repair.stdout) : 'unknown' + if (repaired === 'unknown') { + health.repairFailedAt = this.deps.now() + this.deps.log(`[wsl] could not restore Windows interop in ${distro} (exit ${repair.code})`) + return 'repair-failed' + } + if (repaired === 'present') { + // Another repair (or WSL itself) restored it between probe and repair. + return 'intact' + } + this.deps.log( + `[wsl] restored the WSLInterop binfmt entry from ${distro}; another distro's systemd ` + + 'shutdown removed it (fixed in WSL 3.0.1, run `wsl --update`)' + ) + return 'repaired' + } + + private async isRunning(distro: string): Promise { + try { + const running = await this.deps.listRunningDistros() + return running.some((name) => name.toLowerCase() === distro.toLowerCase()) + } catch { + // Unconfirmed: `wsl -d` would boot a stopped distro, so skip. + return false + } + } + + private async resolveName(distro: string | null): Promise { + const trimmed = distro?.trim() + if (trimmed) { + return trimmed + } + try { + return await this.deps.resolveDefaultDistro() + } catch { + return null + } + } + + private healthFor(distro: string): DistroHealth { + const key = distro.toLowerCase() + let health = this.distros.get(key) + if (!health) { + health = { + name: distro, + lastCheckAt: -Infinity, + repairFailedAt: -Infinity, + scheduled: false, + inFlight: null + } + this.distros.set(key, health) + } + return health + } + + dispose(): void { + this.unsubscribe?.() + this.unsubscribe = null + this.distros.clear() + } +} + +export const wslInteropHealthMonitor = new WslInteropHealthMonitor({ + platform: () => process.platform, + listRunningDistros: () => listRunningWslDistrosAsync({ requireConfirmed: true }), + resolveDefaultDistro: async () => (await listWslDistrosAsync())[0] ?? null, + runScript: (distro, script, user) => + runWslProcess({ distro, user, loginPath: 'none', script, timeoutMs: PROBE_TIMEOUT_MS }), + subscribeStops: onRunningWslDistrosStopped, + now: () => Date.now(), + schedule: (fn, delayMs) => { + setTimeout(fn, delayMs).unref?.() + }, + log: (message) => console.warn(message) +}) diff --git a/src/main/wsl/wsl-interop-registration.test.ts b/src/main/wsl/wsl-interop-registration.test.ts new file mode 100644 index 00000000000..149ea3ffef3 --- /dev/null +++ b/src/main/wsl/wsl-interop-registration.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + isWslConfInteropDisabled, + parseWslInteropProbeOutput, + parseWslInteropRepairOutput, + WSL_INTEROP_PROBE_SCRIPT, + WSL_INTEROP_REPAIR_SCRIPT +} from './wsl-interop-registration' + +describe('parseWslInteropProbeOutput', () => { + it('reads present, unmounted and garbage', () => { + expect(parseWslInteropProbeOutput('orca-wsl-interop:present\n')).toEqual({ state: 'present' }) + expect(parseWslInteropProbeOutput('motd\r\norca-wsl-interop:unmounted\r\n')).toEqual({ + state: 'unmounted' + }) + expect(parseWslInteropProbeOutput('')).toEqual({ state: 'unknown' }) + expect(parseWslInteropProbeOutput('Exec format error')).toEqual({ state: 'unknown' }) + }) + + it('carries the wsl.conf interop setting when the entry is missing', () => { + expect( + parseWslInteropProbeOutput( + 'orca-wsl-interop:missing\norca-wsl-interop:conf\n[boot]\nsystemd=true\n' + ) + ).toEqual({ state: 'missing', interopDisabled: false }) + expect( + parseWslInteropProbeOutput( + 'orca-wsl-interop:missing\norca-wsl-interop:conf\n[interop]\nenabled=false\n' + ) + ).toEqual({ state: 'missing', interopDisabled: true }) + }) +}) + +describe('isWslConfInteropDisabled', () => { + it.each([ + ['[interop]\nenabled=false', true], + ['[Interop]\n Enabled = FALSE # off', true], + ['[interop]\nenabled="false"', true], + ['[interop]\nenabled=0', true], + ['[interop]\nenabled=true', false], + ['[interop]\nappendWindowsPath=false', false], + ['[automount]\nenabled=false', false], + ['# [interop]\n# enabled=false', false], + ['[interop]\nenabled=false\nenabled=true', false], + ['', false] + ])('%j -> %s', (conf, expected) => { + expect(isWslConfInteropDisabled(conf)).toBe(expected) + }) +}) + +describe('scripts', () => { + it('accepts either interop entry name and never flushes binfmt', () => { + for (const script of [WSL_INTEROP_PROBE_SCRIPT, WSL_INTEROP_REPAIR_SCRIPT]) { + expect(script).toContain('/proc/sys/fs/binfmt_misc/WSLInterop-late') + expect(script).not.toMatch(/systemd-binfmt|\/status/) + // --exec passes argv verbatim, but keep the payload free of guest expansions anyway. + expect(script).not.toContain('$') + } + expect(WSL_INTEROP_REPAIR_SCRIPT).toContain( + "printf ':WSLInterop:M::MZ::/init:P' > /proc/sys/fs/binfmt_misc/register" + ) + // A failed write must not decide the verdict; only the re-check does. + expect(WSL_INTEROP_REPAIR_SCRIPT).not.toContain('|| exit') + }) + + it('parses repair results', () => { + expect(parseWslInteropRepairOutput('orca-wsl-interop:repaired\n')).toBe('repaired') + expect(parseWslInteropRepairOutput('orca-wsl-interop:present\n')).toBe('present') + expect(parseWslInteropRepairOutput('sh: permission denied')).toBe('unknown') + }) +}) diff --git a/src/main/wsl/wsl-interop-registration.ts b/src/main/wsl/wsl-interop-registration.ts new file mode 100644 index 00000000000..0a4a8c2ce70 --- /dev/null +++ b/src/main/wsl/wsl-interop-registration.ts @@ -0,0 +1,105 @@ +/** + * Guest-side detection and repair of the `WSLInterop` binfmt_misc entry. + * + * Why: on WSL < 3.0.1 a graceful stop of ANY systemd distro (including the + * ~15 s idle stop) writes -1 to binfmt_misc/status, which deletes every entry + * VM-wide. Every other running distro then fails to exec Windows binaries + * (`code .`, `explorer.exe`, Orca's WSL CLI bridge) with "Exec format error". + * Fixed upstream in microsoft/WSL#13885. The registry is shared by all + * distros, so one repair restores every one of them. + */ + +export type WslInteropProbeResult = + | { state: 'present' } + /** binfmt_misc is not mounted, so there is nothing Orca can safely restore. */ + | { state: 'unmounted' } + | { state: 'missing'; interopDisabled: boolean } + | { state: 'unknown' } + +const MARKER = 'orca-wsl-interop:' +const BINFMT_DIR = '/proc/sys/fs/binfmt_misc' + +// Why both names: some WSL builds register `WSLInterop-late` after systemd starts. +const PRESENT_TEST = `[ -e ${BINFMT_DIR}/WSLInterop ] || [ -e ${BINFMT_DIR}/WSLInterop-late ]` + +/** Read-only; runs as the default user. wsl.conf is only printed when it matters. */ +export const WSL_INTEROP_PROBE_SCRIPT = [ + `if [ ! -e ${BINFMT_DIR}/register ]; then echo '${MARKER}unmounted'`, + `elif ${PRESENT_TEST}; then echo '${MARKER}present'`, + `else echo '${MARKER}missing'; echo '${MARKER}conf'; cat /etc/wsl.conf 2>/dev/null; fi` +].join('\n') + +/** + * Idempotent; runs as root. Re-checks so a concurrent repair (or WSL itself) + * never gets a duplicate entry. Never `systemctl restart systemd-binfmt`: + * that flushes every entry first. Flags match WSL's own `P` registration; `F` + * would pin the repairing distro's /init open for the life of the VM. + */ +export const WSL_INTEROP_REPAIR_SCRIPT = [ + `if ${PRESENT_TEST}; then echo '${MARKER}present'; exit 0; fi`, + // No trailing newline: busybox printf writes it separately and the kernel rejects that write + // with EINVAL after registering. The re-check below, not the write status, is the verdict. + `printf ':WSLInterop:M::MZ::/init:P' > ${BINFMT_DIR}/register`, + `if ${PRESENT_TEST}; then echo '${MARKER}repaired'; else exit 1; fi` +].join('\n') + +function markerLines(stdout: string): string[] { + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith(MARKER)) +} + +export function parseWslInteropProbeOutput(stdout: string): WslInteropProbeResult { + const [first] = markerLines(stdout) + if (first === `${MARKER}present`) { + return { state: 'present' } + } + if (first === `${MARKER}unmounted`) { + return { state: 'unmounted' } + } + if (first === `${MARKER}missing`) { + const confStart = stdout.indexOf(`${MARKER}conf`) + const conf = confStart !== -1 ? stdout.slice(confStart + `${MARKER}conf`.length) : '' + return { state: 'missing', interopDisabled: isWslConfInteropDisabled(conf) } + } + return { state: 'unknown' } +} + +export function parseWslInteropRepairOutput(stdout: string): 'present' | 'repaired' | 'unknown' { + const [first] = markerLines(stdout) + if (first === `${MARKER}present`) { + return 'present' + } + return first === `${MARKER}repaired` ? 'repaired' : 'unknown' +} + +const FALSE_VALUES = new Set(['false', '0', 'no', 'off']) + +/** True when /etc/wsl.conf sets `[interop] enabled = false`. */ +export function isWslConfInteropDisabled(conf: string): boolean { + let section = '' + let disabled = false + for (const rawLine of conf.split(/\r?\n/)) { + const line = rawLine.replace(/[#;].*$/, '').trim() + const header = /^\[([^\]]*)\]$/.exec(line) + if (header) { + section = header[1].trim().toLowerCase() + continue + } + if (section !== 'interop') { + continue + } + const entry = /^([^=]+)=(.*)$/.exec(line) + if (!entry || entry[1].trim().toLowerCase() !== 'enabled') { + continue + } + // Last assignment wins, as in any INI reader. + const value = entry[2] + .trim() + .replace(/^["']|["']$/g, '') + .toLowerCase() + disabled = FALSE_VALUES.has(value) + } + return disabled +} diff --git a/src/main/wsl/wsl-runner.test.ts b/src/main/wsl/wsl-runner.test.ts index 4786742258b..4402e2bd9ee 100644 --- a/src/main/wsl/wsl-runner.test.ts +++ b/src/main/wsl/wsl-runner.test.ts @@ -73,6 +73,11 @@ describe('separator', () => { await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', program: '/bin/true' }) expect(lastArgv().slice(0, 3)).toEqual(['-d', 'Ubuntu', '--exec']) }) + + it('passes the guest user before --exec', async () => { + await runWslProcess({ loginPath: 'none', distro: 'Ubuntu', user: 'root', program: '/bin/true' }) + expect(lastArgv().slice(0, 5)).toEqual(['-d', 'Ubuntu', '-u', 'root', '--exec']) + }) }) describe('probe lane', () => { diff --git a/src/main/wsl/wsl-runner.ts b/src/main/wsl/wsl-runner.ts index b063c76c36f..1fef6373932 100644 --- a/src/main/wsl/wsl-runner.ts +++ b/src/main/wsl/wsl-runner.ts @@ -64,6 +64,8 @@ export type WslCommand = export type WslSpec = WslCommand & { /** Undefined selects the distro's default. */ distro?: string + /** Guest user; undefined runs as the distro's default user. */ + user?: 'root' /** Required, with no default: the wrong answer here is the defect this file exists to prevent. */ loginPath: WslLoginPath /** Guest (POSIX) path. */ @@ -224,7 +226,10 @@ export async function runWslProcess(spec: WslSpec): Promise { const argvForm = buildGuestArgv(environment, spec, 'argv') // Measure what is actually spawned: `wsl.exe` and `-d --exec` are // prepended after this point and are part of the same budget. - const fullLine = [resolveWslExecutablePath(), ...buildWslExecArgs(spec.distro, argvForm)] + const fullLine = [ + resolveWslExecutablePath(), + ...buildWslExecArgs(spec.distro, argvForm, { user: spec.user }) + ] // Argv is the default, but it has a hard ceiling that stdin does not. A user's // `orca.yaml` hook is the one unbounded script Orca runs, so past the cap the // choice is between failing to spawn at all and accepting the stdin caveat. @@ -239,7 +244,7 @@ export async function runWslProcess(spec: WslSpec): Promise { const remainingMs = Math.max(1, deadline - Date.now()) const result = await runProcess({ program: resolveWslExecutablePath(), - args: buildWslExecArgs(spec.distro, argv), + args: buildWslExecArgs(spec.distro, argv, { user: spec.user }), // Name a Windows directory rather than inheriting one: an inherited cwd that // is later deleted (the worktree Orca launched from) fails every later spawn // (#16463). Never the guest cwd -- withGuestCwd still cds inside. diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts index 447805b130d..9058ea12305 100644 --- a/src/shared/wsl-login-shell-command.ts +++ b/src/shared/wsl-login-shell-command.ts @@ -17,9 +17,15 @@ export function quotePosixShell(value: string): string { */ export function buildWslExecArgs( distro: string | undefined, - shellArgs: readonly string[] + shellArgs: readonly string[], + options: { user?: string } = {} ): string[] { - return [...(distro ? ['-d', distro] : []), '--exec', ...shellArgs] + return [ + ...(distro ? ['-d', distro] : []), + ...(options.user ? ['-u', options.user] : []), + '--exec', + ...shellArgs + ] } export function buildWslLoginShellCommand(command: string): string {