mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
fix(codex): stop a cold WSL distro from reading as "this home is not yours"
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.
This commit is contained in:
@@ -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<number, string>([
|
||||
[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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ''
|
||||
}),
|
||||
|
||||
@@ -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<string, string>()
|
||||
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<typeof NodeFs>()
|
||||
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<typeof WslPaths>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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, '\\')}`
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, string>([
|
||||
['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.')
|
||||
}
|
||||
Reference in New Issue
Block a user