From 4da6859b04ed9ee5ae15bdfd74883f0d325467b8 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Tue, 1 Sep 2026 12:28:49 -0700 Subject: [PATCH] fix(codex): stop a cold WSL distro from reading as "this home is not yours" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WSL ownership probe collapsed every failure into a trust verdict. Under `set -euo pipefail` an absent home, a marker owned by another account, a `readlink` failure, and `wsl.exe` failing to start a cold distro all aborted with the same status and empty stdout, and the catch-all relabelled the lot as `Managed WSL Codex home is outside Orca account storage.` No exit code was evidence of *which* happened, so no caller could tell a proven trust failure from "we could not look" — the exact category error STA-4422 removed from the host lane. That is not cosmetic. `removeUnlessUnproven` keeps the managed home only for a `ManagedCodexHomeTemporarilyUnavailableError`; a fail-once probe during add therefore produced a plain trust error, and the rollback's own re-gate then succeeded (the fault was transient) and deleted the home with the credentials `codex login` had just written into it. The guest now states its observation on a tagged line and exits 0. Only a parsed tag is dispositive; a throw from the runner, a timeout, no tag, extra output, or an unknown tag are all indeterminate. Three lanes are classified against the tri-state the host already had: - the `wsl.exe` probe, through the new tagged protocol; - the mounted lane, which used `existsSync` and so folded EPERM/EIO into "does not exist"; - `ensureExpectedWslHome`, where exits 41/42 stay dispositive and every other non-zero exit or timeout becomes indeterminate. The two structural checks on the persisted path spelling now throw the typed untrusted error rather than a bare `Error`, since the host already holds the facts they test. `assertOwnedCodexManagedHomeVerdict` and the ownership-marker message are shared by both Codex lanes so neither can invent its own error mapping. Reconciling this vocabulary with the Claude lane is still open: the module it would merge with (`claude-managed-auth-ownership.ts`, PR #17993) is not on main yet, so there is nothing here to extract against. Fixes STA-5616. --- .../codex-accounts/codex-managed-home-path.ts | 149 ++++--- .../host-codex-managed-home-ownership.ts | 11 +- .../service-wsl-accounts.test.ts | 23 +- ...6-wsl-ownership-transient-collapse.test.ts | 386 ++++++++++++++++++ .../wsl-codex-managed-home-probe.test.ts | 123 ++++++ .../wsl-codex-managed-home-probe.ts | 122 ++++++ 6 files changed, 746 insertions(+), 68 deletions(-) create mode 100644 src/main/codex-accounts/sta-5616-wsl-ownership-transient-collapse.test.ts create mode 100644 src/main/codex-accounts/wsl-codex-managed-home-probe.test.ts create mode 100644 src/main/codex-accounts/wsl-codex-managed-home-probe.ts diff --git a/src/main/codex-accounts/codex-managed-home-path.ts b/src/main/codex-accounts/codex-managed-home-path.ts index 012ef943aa4..6892e4197aa 100644 --- a/src/main/codex-accounts/codex-managed-home-path.ts +++ b/src/main/codex-accounts/codex-managed-home-path.ts @@ -1,16 +1,37 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { app } from 'electron' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { quotePosixShell } from '../../shared/wsl-login-shell-command' import { parseWslUncPath } from '../../shared/wsl-paths' import type { CodexManagedAccount } from '../../shared/managed-account-types' import { getSystemCodexHomePath } from '../codex/codex-home-paths' -import { toWindowsWslPath } from '../wsl' import { runWslProcess } from '../wsl/wsl-runner' -import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' +import { + assertOwnedCodexManagedHomeVerdict, + assertOwnedHostCodexManagedHomePath, + ManagedCodexHomeTemporarilyUnavailableError, + MISSING_MANAGED_HOME_MESSAGE, + MISSING_OWNERSHIP_MARKER_MESSAGE, + UntrustedManagedCodexHomeError, + type HostCodexManagedHomeVerdict +} from './host-codex-managed-home-ownership' +import { + ACCOUNT_ID_MISMATCH_MESSAGE, + buildWslCodexManagedHomeProbeScript, + classifyWslCodexManagedHomeProbe, + MARKER_ACCOUNT_MISMATCH_MESSAGE, + OUTSIDE_MANAGED_ROOT_MESSAGE +} from './wsl-codex-managed-home-probe' const WSL_MANAGED_HOME_TIMEOUT_MS = 5_000 +/** Exit codes the preparation script uses to report a *proven* foreign directory. */ +const WSL_PREPARE_UNTRUSTED_EXITS = new Map([ + [41, MISSING_OWNERSHIP_MARKER_MESSAGE], + [42, MARKER_ACCOUNT_MISMATCH_MESSAGE] +]) + export class CodexManagedHomePath { constructor(private readonly validateWslPath: (distro: string, script: string) => string) {} @@ -56,22 +77,26 @@ export class CodexManagedHomePath { expectedAccountId }) } + // Why: the spelling of the persisted path is a fact the host already holds, + // so a mismatch is dispositive without asking the guest anything. if ( !wslInfo.linuxPath.includes('/.local/share/orca/codex-accounts/') || !wslInfo.linuxPath.endsWith('/home') ) { - throw new Error('Managed WSL Codex home is outside Orca account storage.') + throw new UntrustedManagedCodexHomeError(OUTSIDE_MANAGED_ROOT_MESSAGE) } if ( expectedAccountId !== undefined && !wslInfo.linuxPath.endsWith(`/.local/share/orca/codex-accounts/${expectedAccountId}/home`) ) { - throw new Error('Managed WSL Codex home does not match its persisted account ID.') + throw new UntrustedManagedCodexHomeError(ACCOUNT_ID_MISMATCH_MESSAGE) } if (process.platform === 'win32') { return this.assertWindowsWslPath(wslInfo, expectedAccountId) } - return this.assertMountedWslPath(candidatePath, wslInfo.linuxPath, expectedAccountId) + return assertOwnedCodexManagedHomeVerdict( + this.resolveMountedWslVerdict(candidatePath, wslInfo.linuxPath, expectedAccountId) + ) } private recreateExpectedHostHome(account: CodexManagedAccount, originalError: unknown): string { @@ -113,12 +138,28 @@ export class CodexManagedHomePath { shell: 'bash', timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS }) + if (result.timedOut) { + throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { + cause: new Error( + `Preparing the managed Codex home in WSL ${wslInfo.distro} timed out after ${WSL_MANAGED_HOME_TIMEOUT_MS}ms.` + ) + }) + } // Why: 41/42 mean the path is not this account's home; re-auth must refuse - // rather than write credentials into someone else's directory. - if (result.code !== 0 || result.timedOut) { - throw new Error( - `Could not prepare the managed Codex home in WSL ${wslInfo.distro} for re-authentication.` - ) + // rather than write credentials into someone else's directory. Every other + // non-zero exit — a cold distro, a missing shell, a 9p hiccup — proves + // nothing about ownership and must not read as a trust failure (STA-5616). + const untrustedReason = + typeof result.code === 'number' ? WSL_PREPARE_UNTRUSTED_EXITS.get(result.code) : undefined + if (untrustedReason !== undefined) { + throw new UntrustedManagedCodexHomeError(untrustedReason) + } + if (result.code !== 0) { + throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { + cause: new Error( + `Preparing the managed Codex home in WSL ${wslInfo.distro} exited with code ${String(result.code)}.` + ) + }) } } @@ -126,68 +167,64 @@ export class CodexManagedHomePath { wslInfo: { distro: string; linuxPath: string }, expectedAccountId?: string ): string { + const script = buildWslCodexManagedHomeProbeScript(wslInfo.linuxPath, expectedAccountId) + let stdout: string try { - const canonicalLinuxPath = this.validateWslPath( - wslInfo.distro, - [ - 'set -euo pipefail', - `candidate=${quotePosixShell(wslInfo.linuxPath)}`, - 'managed_root="${HOME%/}/.local/share/orca/codex-accounts"', - 'candidate_real=$(readlink -f -- "$candidate")', - 'managed_root_real=$(readlink -f -- "$managed_root")', - 'test -f "$candidate_real/.orca-managed-home"', - ...(expectedAccountId === undefined - ? [ - 'case "$candidate_real" in "$managed_root_real"/*/home) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' - ] - : [ - `expected_marker=${quotePosixShell(expectedAccountId)}`, - 'test "$candidate_real" = "$managed_root_real/$expected_marker/home"', - 'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"', - 'printf "%s\\n" "$candidate_real"' - ]) - ].join('\n') - ).trim() - if (!canonicalLinuxPath) { - throw new Error('Managed Codex home directory does not exist on disk.') - } - return toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) + stdout = this.validateWslPath(wslInfo.distro, script) } catch (error) { - throw new Error('Managed WSL Codex home is outside Orca account storage.', { - cause: error - }) + // Why: the guest reports its observation on a tagged line and always exits + // 0, so a throw here is the runner failing — never evidence about the home. + return assertOwnedCodexManagedHomeVerdict( + classifyWslCodexManagedHomeProbe({ ran: false, error }, wslInfo.distro) + ) } + return assertOwnedCodexManagedHomeVerdict( + classifyWslCodexManagedHomeProbe({ ran: true, stdout }, wslInfo.distro) + ) } - private assertMountedWslPath( + /** + * The same gate for a WSL home reached through a mount rather than `wsl.exe`. + * Reads go through `statSync`/`lstatSync` rather than `existsSync` so an EPERM + * or EIO cannot be folded into "does not exist" (STA-4422's collapse). + */ + private resolveMountedWslVerdict( candidatePath: string, linuxPath: string, expectedAccountId?: string - ): string { + ): HostCodexManagedHomeVerdict { if (linuxPath.split('/').includes('..')) { - throw new Error('Managed WSL Codex home is outside Orca account storage.') + return { kind: 'untrusted', reason: OUTSIDE_MANAGED_ROOT_MESSAGE } } - if (!existsSync(candidatePath)) { - throw new Error('Managed Codex home directory does not exist on disk.') + try { + statSync(candidatePath) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return { kind: 'untrusted', reason: MISSING_MANAGED_HOME_MESSAGE } + } + return { kind: 'indeterminate', error } } const markerPath = join(candidatePath, '.orca-managed-home') - if (!existsSync(markerPath)) { - throw new Error('Managed Codex home is missing Orca ownership marker.') + let markerContents: string + try { + if (!lstatSync(markerPath).isFile()) { + return { kind: 'untrusted', reason: MISSING_OWNERSHIP_MARKER_MESSAGE } + } + markerContents = readFileSync(markerPath, 'utf-8') + } catch (error) { + if (isDefinitiveAbsence(error)) { + return { kind: 'untrusted', reason: MISSING_OWNERSHIP_MARKER_MESSAGE } + } + return { kind: 'indeterminate', error } } - if ( - expectedAccountId !== undefined && - readFileSync(markerPath, 'utf-8').trim() !== expectedAccountId - ) { - throw new Error('Managed WSL Codex home ownership marker does not match its account ID.') + if (expectedAccountId !== undefined && markerContents.trim() !== expectedAccountId) { + return { kind: 'untrusted', reason: MARKER_ACCOUNT_MISMATCH_MESSAGE } } - return candidatePath + return { kind: 'owned', homePath: candidatePath } } private isMissingHomeError(error: unknown): boolean { - return ( - error instanceof Error && - error.message === 'Managed Codex home directory does not exist on disk.' - ) + return error instanceof Error && error.message === MISSING_MANAGED_HOME_MESSAGE } private pathsEqual(left: string, right: string): boolean { diff --git a/src/main/codex-accounts/host-codex-managed-home-ownership.ts b/src/main/codex-accounts/host-codex-managed-home-ownership.ts index 1820325247c..18a75f235a9 100644 --- a/src/main/codex-accounts/host-codex-managed-home-ownership.ts +++ b/src/main/codex-accounts/host-codex-managed-home-ownership.ts @@ -10,6 +10,9 @@ type HostCodexManagedHomeOwnershipOptions = { } export const MISSING_MANAGED_HOME_MESSAGE = 'Managed Codex home directory does not exist on disk.' +/** Shared with the WSL probe so the two lanes cannot drift apart on this wording. */ +export const MISSING_OWNERSHIP_MARKER_MESSAGE = + 'Managed Codex home is missing Orca ownership marker.' /** * Why: the gate answers two different questions and callers act on them very @@ -156,7 +159,7 @@ function evaluate({ // Why: the marker is required, so its definitive absence is structural — but // an unreadable marker is not evidence of anything. if (isDefinitiveAbsence(error)) { - return { kind: 'untrusted', reason: 'Managed Codex home is missing Orca ownership marker.' } + return { kind: 'untrusted', reason: MISSING_OWNERSHIP_MARKER_MESSAGE } } return { kind: 'indeterminate', error } } @@ -193,7 +196,11 @@ export function resolveHostCodexManagedHomeVerdict( export function assertOwnedHostCodexManagedHomePath( options: HostCodexManagedHomeOwnershipOptions ): string { - const verdict = evaluate(options) + return assertOwnedCodexManagedHomeVerdict(evaluate(options)) +} + +/** Shared by the host and WSL lanes so neither can invent its own error mapping. */ +export function assertOwnedCodexManagedHomeVerdict(verdict: HostCodexManagedHomeVerdict): string { if (verdict.kind === 'owned') { return verdict.homePath } diff --git a/src/main/codex-accounts/service-wsl-accounts.test.ts b/src/main/codex-accounts/service-wsl-accounts.test.ts index 0a1e69a81cd..c3a58205270 100644 --- a/src/main/codex-accounts/service-wsl-accounts.test.ts +++ b/src/main/codex-accounts/service-wsl-accounts.test.ts @@ -34,6 +34,11 @@ function decodeEncodedWslBashCommand(command: string): string { return encoded ? Buffer.from(encoded, 'base64').toString('utf8') : command } +/** The tagged line the guest prints for an Orca-owned home (STA-5616 protocol). */ +function ownedProbeVerdict(linuxPath: string): string { + return `ORCA_CODEX_HOME_VERDICT:owned:${Buffer.from(linuxPath, 'utf-8').toString('base64')}\n` +} + function wslOk(stdout = ''): WslResult { return { environmentResolved: true, code: 0, stdout, stderr: '', timedOut: false } } @@ -133,7 +138,7 @@ describe('CodexAccountService config sync', () => { writeFileSync(wslCanonicalConfigPath, 'model_instructions_file = "instructions.md"\n', 'utf-8') vi.doMock('node:child_process', () => ({ - execFileSync: vi.fn(() => `${wslLinuxHomePath}\n`), + execFileSync: vi.fn(() => ownedProbeVerdict(wslLinuxHomePath)), spawn: vi.fn() })) vi.doMock('../../shared/wsl-paths', () => ({ @@ -210,7 +215,7 @@ describe('CodexAccountService config sync', () => { const script = decodeEncodedWslBashCommand(String(args.at(-1))) expect(args.slice(0, 2)).toEqual(['-d', 'Debian']) expect(script).toContain('readlink -f') - return `${wslLinuxHomePath}\n` + return ownedProbeVerdict(wslLinuxHomePath) }) const runWslProcessMock = vi.fn(async (spec: WslSpec) => { const script = String(spec.script) @@ -343,7 +348,7 @@ describe('CodexAccountService config sync', () => { const script = decodeEncodedWslBashCommand(String(args.at(-1))) expect(args.slice(0, 2)).toEqual(['-d', 'Debian']) expect(script).toContain('readlink -f') - return `${wslLinuxHomePath}\n` + return ownedProbeVerdict(wslLinuxHomePath) }) const runWslProcessMock = vi.fn(async (spec: WslSpec) => { const script = String(spec.script) @@ -421,7 +426,7 @@ describe('CodexAccountService config sync', () => { const script = decodeEncodedWslBashCommand(String(args.at(-1))) expect(args.slice(0, 2)).toEqual(['-d', 'Debian']) expect(script).toContain('readlink -f') - return `${wslLinuxHomePath}\n` + return ownedProbeVerdict(wslLinuxHomePath) }) const runWslProcessMock = vi.fn(async (spec: WslSpec) => { const script = String(spec.script) @@ -508,7 +513,7 @@ describe('CodexAccountService config sync', () => { const execFileSyncMock = vi.fn((_command: string, args: string[]) => { const script = decodeEncodedWslBashCommand(String(args.at(-1))) if (script.includes('readlink -f')) { - return `${wslLinuxHomePath}\n` + return ownedProbeVerdict(wslLinuxHomePath) } return '' }) @@ -641,7 +646,7 @@ describe('CodexAccountService config sync', () => { const execFileSyncMock = vi.fn((_command: string, args: string[]) => { const script = decodeEncodedWslBashCommand(String(args.at(-1))) if (script.includes('readlink -f')) { - return `${wslLinuxHomePath}\n` + return ownedProbeVerdict(wslLinuxHomePath) } return '' }) @@ -762,10 +767,8 @@ describe('CodexAccountService config sync', () => { expect(script).toContain( 'test "$candidate_real" = "$managed_root_real/$expected_marker/home"' ) - expect(script).toContain( - 'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"' - ) - return `${wslLinuxHomePath}\n` + expect(script).toContain('test "$contents" = "$expected_marker"') + return ownedProbeVerdict(wslLinuxHomePath) } return '' }), diff --git a/src/main/codex-accounts/sta-5616-wsl-ownership-transient-collapse.test.ts b/src/main/codex-accounts/sta-5616-wsl-ownership-transient-collapse.test.ts new file mode 100644 index 00000000000..369beb84e55 --- /dev/null +++ b/src/main/codex-accounts/sta-5616-wsl-ownership-transient-collapse.test.ts @@ -0,0 +1,386 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import type * as WslPaths from '../../shared/wsl-paths' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// STA-5616 regression: the WSL ownership probe collapsed every failure into a +// TRUST verdict, so a cold distro, a 5s timeout, or a 9p hiccup was +// indistinguishable from "this home is not Orca-owned". The host lane has had +// the owned/untrusted/indeterminate tri-state since STA-4422; only a +// *dispositive* untrusted verdict may clear durable state. + +const rmSyncMock = vi.hoisted(() => vi.fn()) + +/** `rmSync` is mocked below, so fixture teardown needs the unmocked original. */ +const realFs = vi.hoisted(() => ({ rmSync: null as typeof NodeFs.rmSync | null })) + +/** Paths that fail every read with an injected errno, modelling a held 9p/AV lock. */ +const fsFaults = vi.hoisted(() => { + const held = new Map() + return { + hold(path: string, code: string): void { + held.set(path, code) + }, + reset(): void { + held.clear() + }, + consume(target: unknown, syscall: string): void { + const code = typeof target === 'string' ? held.get(target) : undefined + if (!code) { + return + } + const error: NodeJS.ErrnoException = new Error(`${code}: ${syscall} '${String(target)}'`) + error.code = code + error.syscall = syscall + error.path = String(target) + throw error + } + } +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + realFs.rmSync = actual.rmSync + const patched = { + ...actual, + rmSync: rmSyncMock, + statSync: (...args: unknown[]) => { + fsFaults.consume(args[0], 'stat') + return (actual.statSync as (...a: unknown[]) => unknown)(...args) + }, + lstatSync: (...args: unknown[]) => { + fsFaults.consume(args[0], 'lstat') + return (actual.lstatSync as (...a: unknown[]) => unknown)(...args) + } + } + return { ...patched, default: patched } +}) + +vi.mock('electron', () => ({ + app: { getPath: () => '/unused-user-data' } +})) + +/** Lets a real temp dir stand in for a drvfs/9p mount of a WSL managed home. */ +const mountedPathAlias = vi.hoisted(() => ({ hostPath: '', linuxPath: '' })) + +vi.mock('../../shared/wsl-paths', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + parseWslUncPath: (path: string) => + path === mountedPathAlias.hostPath && path !== '' + ? { distro: 'Ubuntu', linuxPath: mountedPathAlias.linuxPath } + : actual.parseWslUncPath(path) + } +}) + +const runWslProcessMock = vi.hoisted(() => vi.fn()) + +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) + +import { CodexManagedHomeLifecycle } from './codex-managed-home-lifecycle' +import { CodexManagedHomePath } from './codex-managed-home-path' +import { + ManagedCodexHomeTemporarilyUnavailableError, + UntrustedManagedCodexHomeError +} from './host-codex-managed-home-ownership' + +const DISTRO = 'Ubuntu' +const ACCOUNT_ID = 'account-1' +const LINUX_HOME = `/home/dev/.local/share/orca/codex-accounts/${ACCOUNT_ID}/home` +const UNC_HOME = `\\\\wsl.localhost\\${DISTRO}${LINUX_HOME.replace(/\//g, '\\')}` + +/** The tagged line the guest prints for an Orca-owned home. */ +function ownedVerdict(linuxPath = LINUX_HOME): string { + return `ORCA_CODEX_HOME_VERDICT:owned:${Buffer.from(linuxPath, 'utf-8').toString('base64')}\n` +} + +/** A `wsl.exe` run that never produced output: killed at the 5s timeout. */ +function timeoutFailure(): Error { + const error = new Error('spawnSync wsl.exe ETIMEDOUT') as NodeJS.ErrnoException & { + signal?: string + status?: number | null + } + error.code = 'ETIMEDOUT' + error.signal = 'SIGTERM' + error.status = null + return error +} + +let originalPlatform: PropertyDescriptor | undefined + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +beforeEach(() => { + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + setPlatform('win32') + rmSyncMock.mockReset() + runWslProcessMock.mockReset() + fsFaults.reset() + mountedPathAlias.hostPath = '' + mountedPathAlias.linuxPath = '' +}) + +afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + vi.restoreAllMocks() +}) + +describe('WSL Codex ownership probe classification', () => { + it('reports a probe timeout as indeterminate, not as an untrusted home', () => { + const paths = new CodexManagedHomePath(() => { + throw timeoutFailure() + }) + + let thrown: unknown + try { + paths.assert(UNC_HOME, ACCOUNT_ID) + } catch (error) { + thrown = error + } + + expect(thrown).toBeInstanceOf(ManagedCodexHomeTemporarilyUnavailableError) + expect(thrown).not.toBeInstanceOf(UntrustedManagedCodexHomeError) + }) + + it('reports a probe that emits no verdict as indeterminate', () => { + const paths = new CodexManagedHomePath(() => '') + + expect(() => paths.assert(UNC_HOME, ACCOUNT_ID)).toThrow( + ManagedCodexHomeTemporarilyUnavailableError + ) + }) + + it('still reports a marker owned by another account as a dispositive untrusted verdict', () => { + const paths = new CodexManagedHomePath(() => 'ORCA_CODEX_HOME_VERDICT:marker-mismatch\n') + + let thrown: unknown + try { + paths.assert(UNC_HOME, ACCOUNT_ID) + } catch (error) { + thrown = error + } + + expect(thrown).toBeInstanceOf(UntrustedManagedCodexHomeError) + expect(thrown).not.toBeInstanceOf(ManagedCodexHomeTemporarilyUnavailableError) + }) + + it('still reports a missing ownership marker as a dispositive untrusted verdict', () => { + const paths = new CodexManagedHomePath(() => 'ORCA_CODEX_HOME_VERDICT:missing-marker\n') + + expect(() => paths.assert(UNC_HOME, ACCOUNT_ID)).toThrow(UntrustedManagedCodexHomeError) + }) + + it('resolves an owned home back to its Windows spelling', () => { + const paths = new CodexManagedHomePath(() => ownedVerdict()) + + expect(paths.assert(UNC_HOME, ACCOUNT_ID)).toBe(UNC_HOME) + }) + + it('rejects a structurally foreign WSL path as untrusted without running a probe', () => { + const probe = vi.fn(() => '') + const paths = new CodexManagedHomePath(probe) + + expect(() => + paths.assert(`\\\\wsl.localhost\\${DISTRO}\\home\\dev\\.codex`, ACCOUNT_ID) + ).toThrow(UntrustedManagedCodexHomeError) + expect(probe).not.toHaveBeenCalled() + }) + + it('rejects a managed path spelled for another account as untrusted without a probe', () => { + const probe = vi.fn(() => '') + const paths = new CodexManagedHomePath(probe) + const otherAccountHome = UNC_HOME.replace(ACCOUNT_ID, 'someone-else') + + expect(() => paths.assert(otherAccountHome, ACCOUNT_ID)).toThrow(UntrustedManagedCodexHomeError) + expect(probe).not.toHaveBeenCalled() + }) +}) + +describe('WSL managed home cleanup after a fail-once probe', () => { + it('does not delete a freshly authenticated WSL home when the probe failed transiently', () => { + // Models the real add path: the ownership re-gate inside identity read hits a + // transient fault, `add` rolls back, and the rollback's own re-gate succeeds + // because the fault was transient. Before STA-5616 that deleted the home. + let calls = 0 + const paths = new CodexManagedHomePath(() => { + calls += 1 + if (calls === 1) { + throw timeoutFailure() + } + return ownedVerdict() + }) + const lifecycle = new CodexManagedHomeLifecycle(paths) + + let addFailure: unknown + try { + paths.assert(UNC_HOME, ACCOUNT_ID) + } catch (error) { + addFailure = error + } + lifecycle.removeUnlessUnproven(addFailure, UNC_HOME, ACCOUNT_ID) + + expect(rmSyncMock).not.toHaveBeenCalled() + }) + + it('still deletes the home when the add failed for a proven reason', () => { + // Control for the case above: same rollback, same healthy probe, but a + // failure that is not an unproven observation. Cleanup must still run. + const paths = new CodexManagedHomePath(() => ownedVerdict()) + const lifecycle = new CodexManagedHomeLifecycle(paths) + + lifecycle.removeUnlessUnproven( + new Error('Codex login completed, but Orca could not resolve the account email.'), + UNC_HOME, + ACCOUNT_ID + ) + + expect(rmSyncMock).toHaveBeenCalled() + }) + + it('refuses to delete a home the probe proved belongs to another account', () => { + const paths = new CodexManagedHomePath(() => 'ORCA_CODEX_HOME_VERDICT:marker-mismatch\n') + const lifecycle = new CodexManagedHomeLifecycle(paths) + + let addFailure: unknown + try { + paths.assert(UNC_HOME, ACCOUNT_ID) + } catch (error) { + addFailure = error + } + lifecycle.removeUnlessUnproven(addFailure, UNC_HOME, ACCOUNT_ID) + + expect(addFailure).toBeInstanceOf(UntrustedManagedCodexHomeError) + expect(rmSyncMock).not.toHaveBeenCalled() + }) +}) + +describe('WSL managed home preparation for re-authentication', () => { + const account = { + id: ACCOUNT_ID, + email: 'dev@example.com', + managedHomePath: UNC_HOME, + managedHomeRuntime: 'wsl' as const, + wslDistro: DISTRO, + wslLinuxHomePath: LINUX_HOME, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } + + // A kill at the deadline can still report a zero status, so `timedOut` has to + // be read on its own rather than inferred from the exit code. + it.each([null, 0])( + 'reports a preparation timed out at exit %s as indeterminate', + async (code) => { + runWslProcessMock.mockResolvedValue({ + code, + stdout: '', + stderr: '', + timedOut: true, + environmentResolved: true + }) + const paths = new CodexManagedHomePath(() => ownedVerdict()) + + await expect(paths.ensureForReauthentication(account)).rejects.toBeInstanceOf( + ManagedCodexHomeTemporarilyUnavailableError + ) + } + ) + + it('reports a foreign directory found during preparation as untrusted', async () => { + runWslProcessMock.mockResolvedValue({ + code: 41, + stdout: '', + stderr: '', + timedOut: false, + environmentResolved: true + }) + const paths = new CodexManagedHomePath(() => ownedVerdict()) + + await expect(paths.ensureForReauthentication(account)).rejects.toBeInstanceOf( + UntrustedManagedCodexHomeError + ) + }) + + it('reports an unexplained preparation exit as indeterminate', async () => { + runWslProcessMock.mockResolvedValue({ + code: 127, + stdout: '', + stderr: 'bash: not found', + timedOut: false, + environmentResolved: true + }) + const paths = new CodexManagedHomePath(() => ownedVerdict()) + + await expect(paths.ensureForReauthentication(account)).rejects.toBeInstanceOf( + ManagedCodexHomeTemporarilyUnavailableError + ) + }) +}) + +describe('mounted WSL Codex home reached through the filesystem, not wsl.exe', () => { + let mountRoot: string + let mountedHome: string + + beforeEach(() => { + setPlatform('linux') + mountRoot = mkdtempSync(join(tmpdir(), 'orca-sta-5616-')) + mountedHome = join(mountRoot, 'home') + mkdirSync(mountedHome, { recursive: true }) + writeFileSync(join(mountedHome, '.orca-managed-home'), `${ACCOUNT_ID}\n`, 'utf-8') + mountedPathAlias.hostPath = mountedHome + mountedPathAlias.linuxPath = LINUX_HOME + }) + + afterEach(() => { + realFs.rmSync!(mountRoot, { recursive: true, force: true }) + }) + + function assertMounted(): string { + return new CodexManagedHomePath(() => '').assert(mountedHome, ACCOUNT_ID) + } + + it('accepts a marked home under the managed root', () => { + expect(assertMounted()).toBe(mountedHome) + }) + + it('reports a home the filesystem refuses to stat as indeterminate', () => { + fsFaults.hold(mountedHome, 'EPERM') + + expect(assertMounted).toThrow(ManagedCodexHomeTemporarilyUnavailableError) + }) + + it('reports an unreadable ownership marker as indeterminate, not as a missing one', () => { + fsFaults.hold(join(mountedHome, '.orca-managed-home'), 'EBUSY') + + expect(assertMounted).toThrow(ManagedCodexHomeTemporarilyUnavailableError) + }) + + it('still reports a definitively absent marker as untrusted', () => { + realFs.rmSync!(join(mountedHome, '.orca-managed-home')) + + expect(assertMounted).toThrow(UntrustedManagedCodexHomeError) + }) + + it('still reports a definitively absent home as untrusted', () => { + realFs.rmSync!(mountedHome, { recursive: true }) + + expect(assertMounted).toThrow(UntrustedManagedCodexHomeError) + }) + + it('still reports a marker owned by another account as untrusted', () => { + writeFileSync(join(mountedHome, '.orca-managed-home'), 'someone-else\n', 'utf-8') + + expect(assertMounted).toThrow(UntrustedManagedCodexHomeError) + }) +}) diff --git a/src/main/codex-accounts/wsl-codex-managed-home-probe.test.ts b/src/main/codex-accounts/wsl-codex-managed-home-probe.test.ts new file mode 100644 index 00000000000..831d945755e --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-managed-home-probe.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../wsl', () => ({ + toWindowsWslPath: (linuxPath: string, distro: string) => + `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` +})) + +import { + ACCOUNT_ID_MISMATCH_MESSAGE, + buildWslCodexManagedHomeProbeScript, + classifyWslCodexManagedHomeProbe, + MARKER_ACCOUNT_MISMATCH_MESSAGE, + OUTSIDE_MANAGED_ROOT_MESSAGE +} from './wsl-codex-managed-home-probe' +import { + MISSING_MANAGED_HOME_MESSAGE, + MISSING_OWNERSHIP_MARKER_MESSAGE +} from './host-codex-managed-home-ownership' + +const DISTRO = 'Ubuntu' +const LINUX_HOME = '/home/dev/.local/share/orca/codex-accounts/account-1/home' + +function tagged(value: string): string { + return `ORCA_CODEX_HOME_VERDICT:${value}\n` +} + +function owned(linuxPath = LINUX_HOME): string { + return tagged(`owned:${Buffer.from(linuxPath, 'utf-8').toString('base64')}`) +} + +describe('buildWslCodexManagedHomeProbeScript', () => { + it('single-quotes the candidate path so a crafted path cannot inject shell', () => { + const script = buildWslCodexManagedHomeProbeScript("/home/dev/'; rm -rf /; '", 'account-1') + + expect(script).toContain(`candidate='/home/dev/'\\''; rm -rf /; '\\'''`) + expect(script).not.toMatch(/^\s*rm -rf/m) + }) + + it('pins the home to the expected account when one is given', () => { + const script = buildWslCodexManagedHomeProbeScript(LINUX_HOME, 'account-1') + + expect(script).toContain(`expected_marker='account-1'`) + expect(script).toContain('test "$candidate_real" = "$managed_root_real/$expected_marker/home"') + expect(script).toContain('test "$contents" = "$expected_marker"') + }) + + it('accepts any managed account home when no account is given', () => { + const script = buildWslCodexManagedHomeProbeScript(LINUX_HOME) + + expect(script).not.toContain('expected_marker') + expect(script).toContain('test -n "$contents" || tag marker-mismatch') + }) + + it('never lets the guest end on a bare non-zero exit for an ownership fact', () => { + const script = buildWslCodexManagedHomeProbeScript(LINUX_HOME, 'account-1') + + // Every structural fact is reported through `tag`, which exits 0; the bare + // `exit 1`s are reserved for reads that failed and prove nothing. + expect(script).not.toContain('exit 35') + expect(script.match(/\|\| exit 1$/gm)?.length).toBeGreaterThan(0) + }) +}) + +describe('classifyWslCodexManagedHomeProbe', () => { + it('treats a runner failure as indeterminate', () => { + const cause = new Error('spawnSync wsl.exe ETIMEDOUT') + + const verdict = classifyWslCodexManagedHomeProbe({ ran: false, error: cause }, DISTRO) + + expect(verdict.kind).toBe('indeterminate') + expect((verdict as { error: Error }).error.cause).toBe(cause) + }) + + it.each([ + ['', 'no output at all'], + ['/home/dev/some/path\n', 'a bare path, as the pre-STA-5616 protocol emitted'], + [`${owned()}trailing chatter\n`, 'output after the verdict'], + [`${owned()}${owned()}`, 'two verdicts'], + [tagged('who-knows'), 'an unrecognised tag'], + [tagged('owned:!!!not-base64!!!'), 'an undecodable path'] + ])('treats %j as indeterminate (%s)', (stdout) => { + expect(classifyWslCodexManagedHomeProbe({ ran: true, stdout }, DISTRO).kind).toBe( + 'indeterminate' + ) + }) + + it.each([ + ['missing-home', MISSING_MANAGED_HOME_MESSAGE], + ['missing-marker', MISSING_OWNERSHIP_MARKER_MESSAGE], + ['marker-mismatch', MARKER_ACCOUNT_MISMATCH_MESSAGE], + ['account-mismatch', ACCOUNT_ID_MISMATCH_MESSAGE], + ['outside-managed-root', OUTSIDE_MANAGED_ROOT_MESSAGE] + ])('treats the %s tag as a dispositive untrusted verdict', (tag, reason) => { + expect(classifyWslCodexManagedHomeProbe({ ran: true, stdout: tagged(tag) }, DISTRO)).toEqual({ + kind: 'untrusted', + reason + }) + }) + + it('decodes an owned verdict back to its Windows spelling', () => { + expect(classifyWslCodexManagedHomeProbe({ ran: true, stdout: owned() }, DISTRO)).toEqual({ + kind: 'owned', + homePath: `\\\\wsl.localhost\\Ubuntu${LINUX_HOME.replace(/\//g, '\\')}` + }) + }) + + it('tolerates CRLF and NUL padding from the wsl.exe pipe', () => { + const stdout = `${String.fromCharCode(0)} ${owned().trimEnd()}\r\n` + + expect(classifyWslCodexManagedHomeProbe({ ran: true, stdout }, DISTRO).kind).toBe('owned') + }) + + it('carries a home whose name embeds the tag without truncating it', () => { + const oddPath = '/home/dev/.local/share/orca/codex-accounts/ORCA_CODEX_HOME_VERDICT:x/home' + + const verdict = classifyWslCodexManagedHomeProbe({ ran: true, stdout: owned(oddPath) }, DISTRO) + + expect(verdict).toEqual({ + kind: 'owned', + homePath: `\\\\wsl.localhost\\Ubuntu${oddPath.replace(/\//g, '\\')}` + }) + }) +}) diff --git a/src/main/codex-accounts/wsl-codex-managed-home-probe.ts b/src/main/codex-accounts/wsl-codex-managed-home-probe.ts new file mode 100644 index 00000000000..e7f01a2600d --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-managed-home-probe.ts @@ -0,0 +1,122 @@ +import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { toWindowsWslPath } from '../wsl' +import { + MISSING_MANAGED_HOME_MESSAGE, + MISSING_OWNERSHIP_MARKER_MESSAGE, + type HostCodexManagedHomeVerdict +} from './host-codex-managed-home-ownership' + +/** + * Why a tagged line instead of exit codes: under `set -e` an absent home, a + * marker owned by another account, a `readlink` failure, and `wsl.exe` failing + * to start a cold distro all abort with the same status and empty stdout, so no + * exit code is observable evidence of *which* happened. The guest states its + * observation and exits 0; only a parsed tag is dispositive, and everything else + * — no tag, extra output, a throw from the runner, a timeout — is indeterminate. + * That inverts the old default under which a cold distro read as "this home is + * not Orca-owned" (STA-5616). + */ +const VERDICT_TAG = 'ORCA_CODEX_HOME_VERDICT:' + +export const OUTSIDE_MANAGED_ROOT_MESSAGE = + 'Managed WSL Codex home is outside Orca account storage.' +export const ACCOUNT_ID_MISMATCH_MESSAGE = + 'Managed WSL Codex home does not match its persisted account ID.' +export const MARKER_ACCOUNT_MISMATCH_MESSAGE = + 'Managed WSL Codex home ownership marker does not match its account ID.' + +/** What the host observed of the probe run itself, before any verdict parsing. */ +export type WslCodexManagedHomeProbeOutcome = + | { ran: true; stdout: string } + | { ran: false; error: unknown } + +/** + * The canonical path is base64'd because it is interpolated into a + * line-oriented protocol: a newline anywhere under `$HOME` would otherwise split + * the verdict in two and read as a malformed probe. + */ +export function buildWslCodexManagedHomeProbeScript( + linuxPath: string, + expectedAccountId?: string +): string { + return [ + 'set -uo pipefail', + `tag() { printf '${VERDICT_TAG}%s\\n' "$1"; exit 0; }`, + `candidate=${quotePosixShell(linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/codex-accounts"', + // Definitive absence is the one structural fact the host lane also treats as + // a verdict; re-auth recreates the home from it rather than refusing. + 'test -e "$candidate" || tag missing-home', + 'candidate_real=$(readlink -f -- "$candidate") || exit 1', + 'managed_root_real=$(readlink -f -- "$managed_root") || exit 1', + 'marker="$candidate_real/.orca-managed-home"', + 'test -f "$marker" || tag missing-marker', + 'contents=$(cat -- "$marker") || exit 1', + 'case "$candidate_real" in "$managed_root_real"/*/home) ;; *) tag outside-managed-root ;; esac', + ...(expectedAccountId === undefined + ? ['test -n "$contents" || tag marker-mismatch'] + : [ + `expected_marker=${quotePosixShell(expectedAccountId)}`, + 'test "$candidate_real" = "$managed_root_real/$expected_marker/home" || tag account-mismatch', + 'test "$contents" = "$expected_marker" || tag marker-mismatch' + ]), + "encoded=$(printf '%s' \"$candidate_real\" | base64 | tr -d '\\n') || exit 1", + 'tag "owned:$encoded"' + ].join('\n') +} + +function indeterminate(message: string, cause?: unknown): HostCodexManagedHomeVerdict { + return { kind: 'indeterminate', error: new Error(message, cause ? { cause } : undefined) } +} + +/** Base64 round-trips so a truncated or garbled payload cannot become a path. */ +function decodeCanonicalPath(encoded: string): string | null { + if (!encoded) { + return null + } + const decoded = Buffer.from(encoded, 'base64').toString('utf-8') + return decoded && Buffer.from(decoded, 'utf-8').toString('base64') === encoded ? decoded : null +} + +const UNTRUSTED_TAGS = new Map([ + ['missing-home', MISSING_MANAGED_HOME_MESSAGE], + ['missing-marker', MISSING_OWNERSHIP_MARKER_MESSAGE], + ['marker-mismatch', MARKER_ACCOUNT_MISMATCH_MESSAGE], + ['account-mismatch', ACCOUNT_ID_MISMATCH_MESSAGE], + ['outside-managed-root', OUTSIDE_MANAGED_ROOT_MESSAGE] +]) + +export function classifyWslCodexManagedHomeProbe( + outcome: WslCodexManagedHomeProbeOutcome, + distro: string +): HostCodexManagedHomeVerdict { + if (!outcome.ran) { + return { + kind: 'indeterminate', + error: new Error('WSL Codex ownership probe could not run.', { cause: outcome.error }) + } + } + const lines = outcome.stdout + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + const tagged = lines.filter((line) => line.startsWith(VERDICT_TAG)) + // The tag is the guest's last act, so anything after it means the run did not + // end where the protocol says it ends. + if (tagged.length !== 1 || lines.at(-1) !== tagged[0]) { + return indeterminate('WSL Codex ownership probe did not report exactly one verdict.') + } + const value = tagged[0].slice(VERDICT_TAG.length) + const untrustedReason = UNTRUSTED_TAGS.get(value) + if (untrustedReason !== undefined) { + return { kind: 'untrusted', reason: untrustedReason } + } + if (!value.startsWith('owned:')) { + return indeterminate('WSL Codex ownership probe reported an unknown verdict.') + } + const canonicalPath = decodeCanonicalPath(value.slice('owned:'.length)) + return canonicalPath + ? { kind: 'owned', homePath: toWindowsWslPath(canonicalPath, distro) } + : indeterminate('WSL Codex ownership probe reported an undecodable path.') +}