diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts index 8d01e00072e..36847613265 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts @@ -19,6 +19,7 @@ import { import { REMOTE_NODE_RUNTIME_READY } from '../ssh/orcad-remote-node-runtime-report' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { runWslProcess, type WslSpec } from '../wsl/wsl-runner' +import { describeWslProcessFailure } from '../wsl/wsl-process-failure' import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime' @@ -97,7 +98,7 @@ async function prepare(distro: string): Promise { maxOutputBytes: 16 * 1024 }) if (result.code !== 0 || result.timedOut) { - throw new Error(`WSL SQLite reader setup failed: ${result.stderr.trim() || 'command failed'}`) + throw new Error(`WSL SQLite reader setup failed: ${describeWslProcessFailure(result)}`) } return result.stdout.trim() } diff --git a/src/main/claude-accounts/claude-profile-wsl-router.test.ts b/src/main/claude-accounts/claude-profile-wsl-router.test.ts index b2f755dcd7d..7b4b9556ca5 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -17,7 +17,12 @@ import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import type { WslSpec } from '../wsl/wsl-runner' import type * as WslPaths from '../../shared/wsl-paths' -const guest = vi.hoisted(() => ({ home: '' })) +const guest = vi.hoisted( + (): { home: string; failure: { code: number; stdout: string } | null } => ({ + home: '', + failure: null + }) +) // Emulate the guest with local POSIX paths and /bin/sh. vi.mock('../../shared/wsl-paths', async (original) => ({ ...(await original()), @@ -29,6 +34,9 @@ vi.mock('../wsl', () => ({ })) vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: async (spec: WslSpec) => { + if (guest.failure) { + return { ...guest.failure, stderr: '', timedOut: false } + } const result = spawnSync('/bin/sh', ['-c', spec.script ?? '', 'sh', ...(spec.args ?? [])], { encoding: 'utf8' }) @@ -49,7 +57,10 @@ import { wslClaudeProfile } from './claude-profile-wsl-paths' // Guest emulation runs POSIX scripts and the Linux Node bundle. const posixHost = process.platform !== 'win32' const roots: string[] = [] -afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) +afterEach(() => { + guest.failure = null + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) function fixture() { const root = mkdtempSync(join(tmpdir(), 'claude-wsl-router-')) @@ -95,6 +106,16 @@ function fixture() { } describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { + it('names why a silent guest command failed instead of a bare "command failed"', async () => { + const f = fixture() + guest.failure = { code: 0xffffffff, stdout: 'Wsl/Service/0x80072747\r\n' } + await expect(f.router.publish('Ubuntu')).rejects.toThrow( + 'WSL Ubuntu: wsl.exe: Wsl/Service/0x80072747' + ) + guest.failure = { code: 3, stdout: '' } + await expect(f.router.publish('Ubuntu')).rejects.toThrow('WSL Ubuntu: exited with code 3') + }) + it('publishes and launches the saved account across distro casing without changing guest spelling', async () => { const f = fixture() await f.router.publish('ubuntu') diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index e5e8fc2c6bb..7c4ec6861f8 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -13,6 +13,7 @@ import { getWslHomeAsync, listRunningWslDistrosAsync } from '../wsl' import { ensureWslPinnedRuntime } from '../wsl/wsl-pinned-runtime' import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import { runWslProcess, type WslSpec } from '../wsl/wsl-runner' +import { describeWslProcessFailure } from '../wsl/wsl-process-failure' import { claudeProfileMarkerPath, type ClaudeProfileDescriptor } from './claude-profile-paths' import type { ClaudeProfileRouterSettings } from './claude-profile-router' import { claudeProfileMissing, claudeProfileSetupFailed } from './claude-profile-launch-errors' @@ -262,9 +263,7 @@ async function writePointer(distro: string, pointer: string, home: string): Prom async function runGuest(distro: string, spec: WslSpec, timeoutMs = 15_000): Promise { const result = await runWslProcess({ ...spec, distro, timeoutMs, maxOutputBytes: 256 * 1024 }) if (result.code !== 0 || result.timedOut) { - throw new Error( - `WSL ${distro}: ${result.stderr.trim() || (result.timedOut ? 'timed out' : 'command failed')}` - ) + throw new Error(`WSL ${distro}: ${describeWslProcessFailure(result)}`) } return result.stdout.trim() } diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index 51a3ea8c59e..478d8d3ad01 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -1,6 +1,7 @@ import type { CliInstallStatus } from '../../shared/cli-install-types' import { getDefaultWslDistro } from '../wsl' import { runWslProcess } from '../wsl/wsl-runner' +import { describeWslProcessFailure } from '../wsl/wsl-process-failure' import { CliInstaller } from './cli-installer' import { buildManagedLegacyRemoveCommand, @@ -305,7 +306,7 @@ async function runWslCommand( throw new WslCliLoginPathUnavailableError(distro) } if (result.code !== 0) { - throw new Error(result.stderr.trim() || `WSL command failed with exit code ${result.code}.`) + throw new Error(describeWslProcessFailure(result)) } return result.stdout } diff --git a/src/main/git/command-runner/wsl-host-failure.ts b/src/main/git/command-runner/wsl-host-failure.ts index 85105e00e5d..b240069994e 100644 --- a/src/main/git/command-runner/wsl-host-failure.ts +++ b/src/main/git/command-runner/wsl-host-failure.ts @@ -1,8 +1,6 @@ +import { isWslHostFailureExitCode, readWslHostDiagnostic } from '../../wsl/wsl-process-failure' import type { ResolvedCommand } from './wsl-command-resolution' -/** wsl.exe's own launch-failure exit, distinct from any status the guest process can return. */ -export const WSL_HOST_FAILURE_EXIT_CODE = 0xffffffff - function outputText(value: unknown): string { if (typeof value === 'string') { return value @@ -26,10 +24,10 @@ export function readWslHostFailureDiagnostic( } const exitCode = typeof code === 'number' ? code : typeof status === 'number' ? status : null // Recognize the host launch-failure shape without reclassifying guest stderr. - if (exitCode !== WSL_HOST_FAILURE_EXIT_CODE || outputText(stderr).trim().length > 0) { + if (!isWslHostFailureExitCode(exitCode) || outputText(stderr).trim().length > 0) { return null } - const diagnostic = outputText(stdout).replaceAll('\u0000', '').trim() + const diagnostic = readWslHostDiagnostic(outputText(stdout)) return diagnostic.length > 0 ? diagnostic : 'wsl.exe reported no diagnostic.' } diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index 99f86ff9f91..bd7af6eb116 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -5,6 +5,7 @@ import { quotePosixShell } from '../shared/shell-argument-quoting' import { removeHostTree } from './host-tree-removal' import { toLinuxPath } from './wsl' import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' +import { describeWslProcessFailure } from './wsl/wsl-process-failure' import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof' export { toHostFilesystemPath, toHostRemovalPath } from './host-tree-removal' @@ -37,7 +38,7 @@ async function runWslCommand(distro: string, command: string): Promise { throw new Error(`WSL filesystem command timed out after ${WSL_FILE_OPERATION_TIMEOUT_MS}ms`) } if (result.code !== 0) { - throw Object.assign(new Error(result.stderr.trim() || `wsl.exe exited ${result.code}`), { + throw Object.assign(new Error(describeWslProcessFailure(result)), { exitCode: result.code, stderr: result.stderr }) diff --git a/src/main/wsl/wsl-process-failure.ts b/src/main/wsl/wsl-process-failure.ts new file mode 100644 index 00000000000..017bb668eab --- /dev/null +++ b/src/main/wsl/wsl-process-failure.ts @@ -0,0 +1,33 @@ +/** wsl.exe's own launch-failure exit, distinct from any status the guest process can return. */ +const WSL_HOST_FAILURE_EXIT_CODE = 0xffffffff + +/** Node reports the unsigned DWORD; >>> 0 also folds a signed -1. */ +export function isWslHostFailureExitCode(code: number | null | undefined): boolean { + return typeof code === 'number' && code >>> 0 === WSL_HOST_FAILURE_EXIT_CODE +} + +/** wsl.exe writes its own errors to stdout; strip UTF-16LE NULs left when WSL_UTF8 was ignored. */ +export function readWslHostDiagnostic(stdout: string): string { + return stdout.replaceAll('\u0000', '').trim() +} + +/** Why a guest command failed: its stderr, else wsl.exe's own error, else the exit status. */ +export function describeWslProcessFailure(result: { + code: number | null + stdout: string + stderr: string + timedOut: boolean +}): string { + const stderr = result.stderr.trim() + if (stderr) { + return stderr + } + if (result.timedOut) { + return 'timed out' + } + if (isWslHostFailureExitCode(result.code)) { + const diagnostic = readWslHostDiagnostic(result.stdout) + return diagnostic ? `wsl.exe: ${diagnostic}` : 'wsl.exe could not start the command.' + } + return `exited with code ${result.code ?? 'unknown'}` +}