From 38fea1d4350fc9d79d2badf9a60764aad782ddc8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:17:49 -0700 Subject: [PATCH] fix(codex): stop WSL credential probes treating read errors as absence STA-4606. An unreadable WSL auth.json or config.toml used to look like a missing file, so sync could null the distro selection, overwrite a rotated token, or rmSync the runtime credential mirror. Classify presence before any write, skip the poll and refuse launch on indeterminate, and recreate a WSL managed home only after a guest-side definitive absence. --- .../codex-accounts/runtime-home-service.ts | 288 ++++++++++------ .../service-wsl-accounts.test.ts | 3 + src/main/codex-accounts/service.ts | 42 +-- ...ta-4606-wsl-reauth-home-recreation.test.ts | 242 ++++++++++++++ ...06-wsl-transient-credential-safety.test.ts | 314 ++++++++++++++++++ .../wsl-managed-home-presence.test.ts | 53 +++ .../wsl-managed-home-presence.ts | 50 +++ src/main/wsl-home-resolution.test.ts | 60 ++++ src/main/wsl.ts | 97 +++--- 9 files changed, 998 insertions(+), 151 deletions(-) create mode 100644 src/main/codex-accounts/sta-4606-wsl-reauth-home-recreation.test.ts create mode 100644 src/main/codex-accounts/sta-4606-wsl-transient-credential-safety.test.ts create mode 100644 src/main/codex-accounts/wsl-managed-home-presence.test.ts create mode 100644 src/main/codex-accounts/wsl-managed-home-presence.ts create mode 100644 src/main/wsl-home-resolution.test.ts diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 242e9d68da9..07944a85ffa 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -66,7 +66,8 @@ import { setSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from './runtime-selection' -import { getDefaultWslDistro, getWslHome } from '../wsl' +import { getDefaultWslDistro, getWslHome, type WslHomeResolution } from '../wsl' +import { observe, observeResolvedPathEntry } from '../codex/codex-path-observation' import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' import { hasCompletedCodexSessionBackfillMarker, @@ -127,7 +128,7 @@ type CodexRuntimeLogoutMarkerStatus = | { kind: 'applies' } | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } -type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' +type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' | 'unverifiable' type CodexReadBackMatch = | { kind: 'matched' @@ -135,7 +136,11 @@ type CodexReadBackMatch = managedAuthPath: string managedAuthContents: string } - | { kind: 'none' | 'ambiguous' } + | { kind: 'none' | 'ambiguous' | 'unverifiable' } + +type WslRuntimeSyncResult = + | { kind: 'ready'; homePath: string | null } + | { kind: 'unavailable'; reason: 'home' | 'files' } function readCodexLastRefresh(authJson: string): number | null { try { @@ -197,6 +202,7 @@ export class CodexRuntimeHomeService { private readonly lastSyncedWslAccountIdByDistro = new Map() private readonly wslRuntimeHomePathByDistro = new Map() private skipNextReadBackForAccountId: string | null = null + private readonly skipNextReadBackForAccountIdByDistro = new Map() // Why: a managed host account refreshes auth in its own home. Remember that // provenance so a later deselect never adopts stale shared bytes. private lastHostAccountUsedSelfContainedHome = false @@ -241,9 +247,16 @@ export class CodexRuntimeHomeService { ): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) - this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath) - const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + const synced = this.syncWslRuntimeForCurrentSelection(wslTarget) + if (synced.kind === 'unavailable') { + throw new ManagedCodexHomeTemporarilyUnavailableError( + synced.reason === 'home' + ? 'The WSL distro could not be reached. Retry when it is available.' + : undefined + ) + } + this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, synced.homePath) + const runtimeHomePath = synced.homePath ?? this.getWslSystemCodexHomePath(wslTarget) this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) return runtimeHomePath } @@ -765,11 +778,11 @@ export class CodexRuntimeHomeService { prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) - return { - kind: 'ready', - codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + const synced = this.getPreparedWslRateLimitHomePath(wslTarget) + if (synced.kind === 'unavailable') { + return { kind: 'skip' } } + return { kind: 'ready', codexHomePath: synced.homePath } } const selfContainedAccount = this.getSelfContainedManagedHostAccount() if (selfContainedAccount) { @@ -904,6 +917,15 @@ export class CodexRuntimeHomeService { if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { this.lastWrittenAuthJson = null } + if (!accountId) { + return + } + const settings = this.store.getSettings() + const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) + const distro = account?.wslDistro?.trim() + if (distro) { + this.skipNextReadBackForAccountIdByDistro.set(distro, accountId) + } this.skipNextReadBackForAccountId = accountId } @@ -916,16 +938,20 @@ export class CodexRuntimeHomeService { expectedAccountId?: string } ): CodexReadBackResult { - try { - if (!existsSync(runtimeAuthPath)) { - return 'unchanged' - } + const observedRuntimeAuth = observe(() => readFileSync(runtimeAuthPath, 'utf-8')) + if (observedRuntimeAuth.kind === 'absent') { + return 'unchanged' + } + if (observedRuntimeAuth.kind === 'indeterminate') { + return 'unverifiable' + } + try { const lastWrittenAuthJson = options.lastWrittenAuthJson === undefined ? this.lastWrittenAuthJson : options.lastWrittenAuthJson - const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') + const runtimeContents = observedRuntimeAuth.value if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) { return 'unchanged' } @@ -934,6 +960,9 @@ export class CodexRuntimeHomeService { runtimeContents, options.expectedAccountId ) + if (match.kind === 'unverifiable') { + return 'unverifiable' + } if (match.kind !== 'matched') { if (match.kind === 'ambiguous') { console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') @@ -958,7 +987,10 @@ export class CodexRuntimeHomeService { } return 'persisted' } catch (error) { - // Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle. + if (!isDefinitiveAbsence(error)) { + console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) + return 'unverifiable' + } console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) return 'rejected' } @@ -1025,14 +1057,20 @@ export class CodexRuntimeHomeService { return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null } - private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { + private getPreparedWslRateLimitHomePath( + target: CodexAccountSelectionTarget + ): WslRuntimeSyncResult { const distro = target.wslDistro?.trim() if (distro) { const settings = this.store.getSettings() const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target) if (selectedAccountId === null) { // Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy. - return this.getWslSystemCodexHomePath(target) + const systemHome = this.getWslSystemCodexHomePath(target) + if (!systemHome && this.resolveWslDistroHome(distro).kind !== 'resolved') { + return { kind: 'unavailable', reason: 'home' } + } + return { kind: 'ready', homePath: systemHome } } const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) if ( @@ -1041,15 +1079,17 @@ export class CodexRuntimeHomeService { this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId ) { // Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe. - return cachedRuntimeHomePath + return { kind: 'ready', homePath: cachedRuntimeHomePath } } } return this.syncWslRuntimeForCurrentSelection(target) } - private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { + private syncWslRuntimeForCurrentSelection( + target: CodexAccountSelectionTarget + ): WslRuntimeSyncResult { if (process.platform !== 'win32') { - return null + return { kind: 'ready', homePath: null } } const wslTarget = this.resolveWslDefaultTarget(target) @@ -1060,51 +1100,73 @@ export class CodexRuntimeHomeService { ) const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() if (!distro) { - return null + return { kind: 'ready', homePath: null } } const runtimeHomePath = this.getWslRuntimeHomePath(distro) if (!runtimeHomePath) { - return null + return { kind: 'unavailable', reason: 'home' } } this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath) mkdirSync(runtimeHomePath, { recursive: true }) this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath) - this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) const runtimeAuthPath = join(runtimeHomePath, 'auth.json') + const seedPlan = this.classifyWslRuntimeConfigSeed(runtimeHomePath, activeAccount, distro) + if (seedPlan === 'unavailable') { + return { kind: 'unavailable', reason: 'files' } + } + const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null - if (previousWslAccountId) { - if (this.skipNextReadBackForAccountId === previousWslAccountId) { + const readBackAccountId = previousWslAccountId ?? activeAccount?.id ?? null + const skipAccountId = + this.skipNextReadBackForAccountIdByDistro.get(distro) ?? this.skipNextReadBackForAccountId + if (readBackAccountId && skipAccountId === readBackAccountId) { + this.skipNextReadBackForAccountIdByDistro.delete(distro) + if (this.skipNextReadBackForAccountId === readBackAccountId) { this.skipNextReadBackForAccountId = null - } else { - const previousWslAccount = this.getActiveAccount( - settings.codexManagedAccounts, - previousWslAccountId - ) - if (previousWslAccount) { - this.readBackRefreshedTokensFromPath(runtimeAuthPath, { - updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, - setLastWrittenAuthJson: (contents) => { - this.lastWrittenWslAuthJsonByDistro.set(distro, contents) - }, - expectedAccountId: previousWslAccount.id - }) + } + } else if (readBackAccountId) { + const readBackAccount = this.getActiveAccount( + settings.codexManagedAccounts, + readBackAccountId + ) + if (readBackAccount) { + const readBack = this.readBackRefreshedTokensFromPath(runtimeAuthPath, { + updateLastWrittenAuthJson: true, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, + setLastWrittenAuthJson: (contents) => { + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) + }, + expectedAccountId: readBackAccount.id + }) + if (readBack === 'unverifiable') { + return { kind: 'unavailable', reason: 'files' } } } } - const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null - if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { - const activeAuth = readFileSync(activeAuthPath, 'utf-8') - this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) - return runtimeHomePath + if (seedPlan === 'seed') { + this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) } + + const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null if (activeAccount && activeAuthPath) { + const managedAuth = observe(() => readFileSync(activeAuthPath, 'utf-8')) + if (managedAuth.kind === 'indeterminate') { + return { kind: 'unavailable', reason: 'files' } + } + if (managedAuth.kind === 'present') { + const runtimeAuth = observe(() => readFileSync(runtimeAuthPath, 'utf-8')) + if (runtimeAuth.kind === 'indeterminate') { + return { kind: 'unavailable', reason: 'files' } + } + this.writeRuntimeAuthAtPath(runtimeAuthPath, managedAuth.value) + this.lastWrittenWslAuthJsonByDistro.set(distro, managedAuth.value) + this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) + return { kind: 'ready', homePath: runtimeHomePath } + } console.warn( '[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default' ) @@ -1119,41 +1181,58 @@ export class CodexRuntimeHomeService { } const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) - if (systemAuthPath && existsSync(systemAuthPath)) { - const systemAuth = readFileSync(systemAuthPath, 'utf-8') + const systemAuth = systemAuthPath + ? observe(() => readFileSync(systemAuthPath, 'utf-8')) + : { kind: 'absent' as const } + if (systemAuth.kind === 'indeterminate') { + return { kind: 'unavailable', reason: 'files' } + } + const runtimeAuth = observe(() => readFileSync(runtimeAuthPath, 'utf-8')) + if (runtimeAuth.kind === 'indeterminate') { + return { kind: 'unavailable', reason: 'files' } + } + if (systemAuthPath && systemAuth.kind === 'present') { + const systemAuthContents = systemAuth.value const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null - const runtimeAuth = existsSync(runtimeAuthPath) - ? readFileSync(runtimeAuthPath, 'utf-8') - : null + const runtimeAuthContents = runtimeAuth.kind === 'present' ? runtimeAuth.value : null if ( - runtimeAuth !== null && - runtimeAuth !== systemAuth && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) && - ((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) || + runtimeAuthContents !== null && + runtimeAuthContents !== systemAuthContents && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuthContents) && + ((mirroredSystemDefaultAuth !== null && systemAuthContents === mirroredSystemDefaultAuth) || (mirroredSystemDefaultAuth === null && - this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) + this.runtimeAuthIsFresher(runtimeAuthContents, systemAuthContents))) ) { // Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex. - this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) + this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuthContents) + this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuthContents) this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath + return { kind: 'ready', homePath: runtimeHomePath } } - this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) + this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuthContents) + this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuthContents) this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath + return { kind: 'ready', homePath: runtimeHomePath } } rmSync(runtimeAuthPath, { force: true }) this.lastWrittenWslAuthJsonByDistro.set(distro, null) this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath + return { kind: 'ready', homePath: runtimeHomePath } } private getWslRuntimeHomePath(distro: string): string | null { + const home = this.resolveWslDistroHome(distro) + return home.kind === 'resolved' + ? this.joinWslPath(home.uncPath, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) + : null + } + + private resolveWslDistroHome(distro: string): WslHomeResolution { const home = getWslHome(distro) - return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null + return home + ? { kind: 'resolved', uncPath: home } + : { kind: 'unavailable', error: new Error('WSL home unresolved') } } private safeReadBackActiveWslAccountBeforeRestart( @@ -1268,29 +1347,54 @@ export class CodexRuntimeHomeService { return home ? this.joinWslPath(home, 'auth.json') : null } + private classifyWslRuntimeConfigSeed( + runtimeHomePath: string, + activeAccount: CodexManagedAccount | null, + distro: string + ): 'keep' | 'seed' | 'unavailable' { + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') + const runtimeConfig = observeResolvedPathEntry(runtimeConfigPath) + if (runtimeConfig.kind === 'present') { + return 'keep' + } + if (runtimeConfig.kind === 'indeterminate') { + return 'unavailable' + } + for (const homePath of this.wslRuntimeConfigSeedHomes(activeAccount, distro)) { + const observed = observe(() => readFileSync(join(homePath, 'config.toml'), 'utf-8')) + if (observed.kind === 'indeterminate') { + return 'unavailable' + } + if (observed.kind === 'present') { + return 'seed' + } + } + return 'keep' + } + + private wslRuntimeConfigSeedHomes( + activeAccount: CodexManagedAccount | null, + distro: string + ): string[] { + return [ + activeAccount?.managedHomePath, + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + ].filter((value): value is string => Boolean(value)) + } + private seedWslRuntimeHome( runtimeHomePath: string, activeAccount: CodexManagedAccount | null, distro: string ): void { const runtimeConfigPath = join(runtimeHomePath, 'config.toml') - if (existsSync(runtimeConfigPath)) { - return - } - - const candidateHomes = [ - activeAccount?.managedHomePath, - this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - ].filter((value): value is string => Boolean(value)) - for (const homePath of candidateHomes) { - const configPath = join(homePath, 'config.toml') - if (existsSync(configPath)) { - writeFileAtomically( - runtimeConfigPath, - prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath) - ) - return + for (const homePath of this.wslRuntimeConfigSeedHomes(activeAccount, distro)) { + const observed = observe(() => readFileSync(join(homePath, 'config.toml'), 'utf-8')) + if (observed.kind !== 'present') { + continue } + writeFileAtomically(runtimeConfigPath, prepareWslRuntimeSeedConfig(observed.value, homePath)) + return } } @@ -1309,32 +1413,26 @@ export class CodexRuntimeHomeService { continue } const managedAuthPath = join(account.managedHomePath, 'auth.json') - if (!existsSync(managedAuthPath)) { + const observedManagedAuth = observe(() => readFileSync(managedAuthPath, 'utf-8')) + if (observedManagedAuth.kind === 'absent') { continue } - let managedAuthContents: string - try { - managedAuthContents = readFileSync(managedAuthPath, 'utf-8') - } catch { - // Why: an unreadable home can never be compared, but letting the read - // throw abandons the scan for every other account — dropping a refresh - // the runtime home holds for one of them. Only its record can rule it - // out as the owner; when it cannot, the scan is no longer unambiguous. - if ( - !expectedAccountId && - codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) - ) { + if (observedManagedAuth.kind === 'indeterminate') { + // Why: an unreadable home can never be compared. Only its record can + // rule it out as the owner; when it cannot, the scan is unverifiable. + if (codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account)) { unreadableHomeCouldOwnRuntimeAuth = true } continue } + const managedAuthContents = observedManagedAuth.value if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { matches.push({ account, managedAuthPath, managedAuthContents }) } } if (unreadableHomeCouldOwnRuntimeAuth) { - return { kind: 'ambiguous' } + return { kind: 'unverifiable' } } if (matches.length === 1) { return { kind: 'matched', ...matches[0] } diff --git a/src/main/codex-accounts/service-wsl-accounts.test.ts b/src/main/codex-accounts/service-wsl-accounts.test.ts index 95f825d3d57..5c7e69efcfd 100644 --- a/src/main/codex-accounts/service-wsl-accounts.test.ts +++ b/src/main/codex-accounts/service-wsl-accounts.test.ts @@ -391,6 +391,9 @@ describe('CodexAccountService config sync', () => { writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8') return '' } + if (script.includes('stat --')) { + throw Object.assign(new Error('No such file or directory'), { status: 2 }) + } if (script.includes('readlink -f')) { return `${wslLinuxHomePath}\n` } diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index a69db117808..be211da5503 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -61,6 +61,11 @@ import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { + buildWslManagedHomeCreateArgs, + buildWslManagedHomePresenceArgs, + classifyWslManagedHomeExecError +} from './wsl-managed-home-presence' const LOGIN_TIMEOUT_MS = 120_000 const MAX_LOGIN_OUTPUT_CHARS = 4_000 @@ -864,6 +869,11 @@ export class CodexAccountService { // runtime home instead of the default host target. this.runtimeHome.syncForCurrentSelection(targetSelection) } catch (error) { + if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { + // Why: login already persisted; a locked runtime must not roll back a + // completed add into doAddAccount's home-deleting catch. + return this.getSnapshot() + } // Why: settings were already written; if a post-write step fails, restore the // previous account/selection so the caller's managed-home cleanup cannot leave // a dangling, broken managed account behind in settings. @@ -1465,27 +1475,21 @@ export class CodexAccountService { return } + try { + execFileSync('wsl.exe', buildWslManagedHomePresenceArgs(wslInfo.distro, wslInfo.linuxPath), { + encoding: 'utf-8', + timeout: 5000 + }) + return + } catch (error) { + if (classifyWslManagedHomeExecError(error) !== 'absent') { + throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: error }) + } + } + execFileSync( 'wsl.exe', - [ - '-d', - wslInfo.distro, - '--exec', - 'bash', - '-lc', - buildEncodedWslBashCommand( - [ - 'set -euo pipefail', - `candidate=${shellQuote(wslInfo.linuxPath)}`, - `expected_marker=${shellQuote(account.id)}`, - 'marker="$candidate/.orca-managed-home"', - 'if [ -e "$candidate" ] && [ ! -f "$marker" ]; then exit 41; fi', - 'if [ -f "$marker" ] && [ "$(cat "$marker")" != "$expected_marker" ]; then exit 42; fi', - 'mkdir -p -- "$candidate"', - 'printf "%s\\n" "$expected_marker" > "$marker"' - ].join('\n') - ) - ], + buildWslManagedHomeCreateArgs(wslInfo.distro, wslInfo.linuxPath, account.id), { encoding: 'utf-8', timeout: 5000 } ) } diff --git a/src/main/codex-accounts/sta-4606-wsl-reauth-home-recreation.test.ts b/src/main/codex-accounts/sta-4606-wsl-reauth-home-recreation.test.ts new file mode 100644 index 00000000000..3771c930917 --- /dev/null +++ b/src/main/codex-accounts/sta-4606-wsl-reauth-home-recreation.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { buildWslCodexLoginArgs } from './wsl-codex-command' +import { + createRateLimits, + createRuntimeHome, + createSettings, + createStore, + registerCodexAccountsTestHomes, + testState +} from './service-test-harness' + +function decodeEncodedWslBashCommand(command: string): string { + const encoded = command.match(/^set -o pipefail; printf %s '([^']+)' \| base64 -d \| bash$/)?.[1] + return encoded ? Buffer.from(encoded, 'base64').toString('utf8') : command +} + +function scriptWritesManagedHome(script: string): boolean { + return script.includes('mkdir -p') && script.includes('.orca-managed-home') +} + +describe('STA-4606 WSL re-auth must not rewrite a home it could not prove absent', () => { + registerCodexAccountsTestHomes() + + it('does not send mkdir/marker rewrite when the guest probe cannot classify the home', async () => { + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-account', 'home') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-1/home' + mkdirSync(wslManagedHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8') + writeFileSync( + join(wslManagedHomePath, 'auth.json'), + JSON.stringify({ + tokens: { + id_token: `header.${Buffer.from(JSON.stringify({ email: 'old@example.com' })).toString( + 'base64url' + )}.signature` + } + }), + 'utf-8' + ) + + const writeScripts: string[] = [] + const execFileSyncMock = vi.fn((_command: string, args: string[]) => { + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + if (scriptWritesManagedHome(script)) { + writeScripts.push(script) + } + const error = Object.assign(new Error('Permission denied'), { status: 13 }) + throw error + }) + const spawnMock = vi.fn(() => { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + queueMicrotask(() => child.emit('close', 0)) + return child + }) + + vi.doMock('node:child_process', () => ({ + execFileSync: execFileSyncMock, + spawn: spawnMock + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: () => wslManagedHomePath + })) + + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'account-1' } + } + }) + const store = createStore(settings) + + try { + const { CodexAccountService } = await import('./service') + const { ManagedCodexHomeTemporarilyUnavailableError } = + await import('./host-codex-managed-home-ownership') + const service = new CodexAccountService( + store as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + + await expect(service.reauthenticateAccount('account-1')).rejects.toBeInstanceOf( + ManagedCodexHomeTemporarilyUnavailableError + ) + expect(writeScripts).toEqual([]) + expect(spawnMock).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('still recreates the home after a definitive guest-side absence', async () => { + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-missing-home', 'home') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-1/home' + mkdirSync(wslManagedHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8') + writeFileSync( + join(wslManagedHomePath, 'auth.json'), + JSON.stringify({ + tokens: { + id_token: `header.${Buffer.from(JSON.stringify({ email: 'old@example.com' })).toString( + 'base64url' + )}.signature` + } + }), + 'utf-8' + ) + + const writeScripts: string[] = [] + const execFileSyncMock = vi.fn((_command: string, args: string[]) => { + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + if (scriptWritesManagedHome(script)) { + writeScripts.push(script) + return '' + } + if (script.includes('readlink -f')) { + return `${wslLinuxHomePath}\n` + } + if (script.includes('stat') || script.includes('[ ! -e') || script.includes('[ -e ')) { + throw Object.assign(new Error('No such file or directory'), { status: 2 }) + } + return '' + }) + const spawnMock = vi.fn((command: string, args: string[]) => { + expect(command).toBe('wsl.exe') + expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath)) + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + writeFileSync( + join(wslManagedHomePath, 'auth.json'), + JSON.stringify({ + tokens: { + id_token: `header.${Buffer.from(JSON.stringify({ email: 'new@example.com' })).toString( + 'base64url' + )}.signature` + } + }), + 'utf-8' + ) + queueMicrotask(() => child.emit('close', 0)) + return child + }) + + vi.doMock('node:child_process', () => ({ + execFileSync: execFileSyncMock, + spawn: spawnMock + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: () => wslManagedHomePath + })) + + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'account-1' } + } + }) + const store = createStore(settings) + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + + const result = await service.reauthenticateAccount('account-1') + expect(writeScripts.length).toBeGreaterThan(0) + expect(result.accounts[0]?.email).toBe('new@example.com') + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) +}) diff --git a/src/main/codex-accounts/sta-4606-wsl-transient-credential-safety.test.ts b/src/main/codex-accounts/sta-4606-wsl-transient-credential-safety.test.ts new file mode 100644 index 00000000000..92976b4b781 --- /dev/null +++ b/src/main/codex-accounts/sta-4606-wsl-transient-credential-safety.test.ts @@ -0,0 +1,314 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import { join } from 'node:path' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createCodexAuthJson, + createManagedAuth, + createStore, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' + +// STA-4606: WSL sync treated an unreadable path as absence, then deselecting, +// overwriting, or deleting credentials. A held EPERM must not authorize those. + +const denials = vi.hoisted(() => { + const state = { + paths: new Set(), + reads: new Map(), + removals: [] as string[], + deny(path: string): void { + state.paths.add(path) + }, + release(path: string): void { + state.paths.delete(path) + }, + readsFor(path: string): number { + return state.reads.get(path) ?? 0 + }, + reset(): void { + state.paths.clear() + state.reads.clear() + state.removals = [] + }, + check(target: unknown, syscall: string): void { + if (typeof target !== 'string' || !state.paths.has(target)) { + return + } + state.reads.set(target, (state.reads.get(target) ?? 0) + 1) + const error: NodeJS.ErrnoException = new Error( + `EPERM: operation not permitted, ${syscall} '${target}'` + ) + error.code = 'EPERM' + error.errno = -4048 + error.syscall = syscall + error.path = target + throw error + } + } + return state +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const guard = (fn: unknown, syscall: string): unknown => { + const original = fn as (...args: unknown[]) => unknown + const wrapped = (...args: unknown[]): unknown => { + denials.check(args[0], syscall) + return original(...args) + } + return Object.assign(wrapped, original) + } + const patched: Record = { + ...actual, + readFileSync: guard(actual.readFileSync, 'read'), + lstatSync: guard(actual.lstatSync, 'lstat'), + statSync: guard(actual.statSync, 'stat'), + accessSync: guard(actual.accessSync, 'access'), + openSync: guard(actual.openSync, 'open'), + rmSync: Object.assign((...args: unknown[]) => { + if (typeof args[0] === 'string') { + denials.removals.push(args[0]) + } + denials.check(args[0], 'rm') + return actual.rmSync(...(args as Parameters)) + }, actual.rmSync), + existsSync: Object.assign((...args: unknown[]): boolean => { + if (typeof args[0] === 'string' && denials.paths.has(args[0])) { + denials.reads.set(args[0], (denials.reads.get(args[0]) ?? 0) + 1) + return false + } + return actual.existsSync(args[0] as string) + }, actual.existsSync) + } + return { ...patched, default: patched } +}) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +const realFs = await vi.importActual('node:fs') + +const WSL_TARGET = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + +function wslRuntimeHomePath(wslHome: string): string { + return join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') +} + +function createWslAccount(managedHomePath: string, id = 'account-1') { + return { + id, + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl' as const, + wslDistro: 'Ubuntu', + wslLinuxHomePath: `/home/alice/.local/share/orca/codex-accounts/${id}/home`, + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + +async function createWslService(options: { + wslHome: string + getWslHome?: () => string | null + managedAuth?: string + omitManagedAuth?: boolean + systemAuth?: string + runtimeAuth?: string + managedConfig?: string + systemConfig?: string +}) { + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: options.getWslHome ?? (() => options.wslHome) + })) + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + options.managedAuth ?? createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed', 1_000) + ) + if (options.omitManagedAuth) { + realFs.rmSync(join(managedHomePath, 'auth.json'), { force: true }) + } + if (options.managedConfig !== undefined) { + realFs.writeFileSync(join(managedHomePath, 'config.toml'), options.managedConfig, 'utf-8') + } + const systemCodexHomePath = join(options.wslHome, '.codex') + realFs.mkdirSync(systemCodexHomePath, { recursive: true }) + if (options.systemAuth !== undefined) { + realFs.writeFileSync(join(systemCodexHomePath, 'auth.json'), options.systemAuth, 'utf-8') + } + if (options.systemConfig !== undefined) { + realFs.writeFileSync(join(systemCodexHomePath, 'config.toml'), options.systemConfig, 'utf-8') + } + const runtimeHome = wslRuntimeHomePath(options.wslHome) + if (options.runtimeAuth !== undefined) { + realFs.mkdirSync(runtimeHome, { recursive: true }) + realFs.writeFileSync(join(runtimeHome, 'auth.json'), options.runtimeAuth, 'utf-8') + } + const store = createStore( + createSettings({ + codexManagedAccounts: [createWslAccount(managedHomePath)], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + return { + service: new CodexRuntimeHomeService(store as never), + store, + managedHomePath, + runtimeHome, + runtimeAuthPath: join(runtimeHome, 'auth.json'), + managedAuthPath: join(managedHomePath, 'auth.json'), + systemAuthPath: join(systemCodexHomePath, 'auth.json'), + runtimeConfigPath: join(runtimeHome, 'config.toml'), + managedConfigPath: join(managedHomePath, 'config.toml'), + systemConfigPath: join(systemCodexHomePath, 'config.toml') + } +} + +describe('STA-4606 WSL unreadable paths must not destroy credentials', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + + beforeEach(() => { + denials.reset() + setupRuntimeHomeTest() + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + denials.reset() + teardownRuntimeHomeTest() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it('does not rmSync the runtime auth mirror when the system auth probe is unreadable', async () => { + const wslHome = join(testState.userDataDir, 'wsl-home') + const runtimeAuth = createCodexAuthJson('runtime@example.com', 'acct-runtime', 'live', 3_000) + const ctx = await createWslService({ + wslHome, + omitManagedAuth: true, + runtimeAuth + }) + denials.deny(ctx.systemAuthPath) + + ctx.service.syncForCurrentSelection(WSL_TARGET) + + expect(denials.readsFor(ctx.systemAuthPath)).toBeGreaterThan(0) + expect(denials.removals).not.toContain(ctx.runtimeAuthPath) + expect(realFs.readFileSync(ctx.runtimeAuthPath, 'utf-8')).toBe(runtimeAuth) + }) + + it('does not overwrite a fresher rotated runtime token after a read-back failure', async () => { + const wslHome = join(testState.userDataDir, 'wsl-home') + const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed-stale', 1_000) + const rotated = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'runtime-rotated', 2_000) + const ctx = await createWslService({ wslHome, managedAuth }) + + expect(ctx.service.prepareForCodexLaunch(WSL_TARGET)).toBe(ctx.runtimeHome) + realFs.writeFileSync(ctx.runtimeAuthPath, rotated, 'utf-8') + denials.deny(ctx.runtimeAuthPath) + + ctx.service.syncForCurrentSelection(WSL_TARGET) + + expect(denials.readsFor(ctx.runtimeAuthPath)).toBeGreaterThan(0) + expect(realFs.readFileSync(ctx.runtimeAuthPath, 'utf-8')).toBe(rotated) + expect(realFs.readFileSync(ctx.managedAuthPath, 'utf-8')).toBe(managedAuth) + expect(ctx.store.updateSettings).not.toHaveBeenCalled() + }) + + it('does not null the distro selection when the managed auth.json is unreadable', async () => { + const wslHome = join(testState.userDataDir, 'wsl-home') + const ctx = await createWslService({ wslHome }) + denials.deny(ctx.managedAuthPath) + + expect(ctx.service.prepareForRateLimitFetch(WSL_TARGET)).toEqual({ kind: 'skip' }) + expect(() => ctx.service.prepareForCodexLaunch(WSL_TARGET)).toThrow(/temporarily locked/) + expect(denials.readsFor(ctx.managedAuthPath)).toBeGreaterThan(0) + expect(ctx.store.updateSettings).not.toHaveBeenCalled() + expect(ctx.store.getSettings().activeCodexManagedAccountIdsByRuntime).toEqual({ + host: null, + wsl: { Ubuntu: 'account-1' } + }) + }) + + it('does not seed a system config into a managed runtime home on an unreadable managed config', async () => { + const wslHome = join(testState.userDataDir, 'wsl-home') + const ctx = await createWslService({ + wslHome, + managedConfig: 'model = "managed-only"\n', + systemConfig: 'model = "system-fallback"\n' + }) + denials.deny(ctx.managedConfigPath) + + ctx.service.syncForCurrentSelection(WSL_TARGET) + + expect(denials.readsFor(ctx.managedConfigPath)).toBeGreaterThan(0) + expect(realFs.existsSync(ctx.runtimeConfigPath)).toBe(false) + }) + + it('still deselects and restores system auth when managed auth.json is proven absent', async () => { + const wslHome = join(testState.userDataDir, 'wsl-home') + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') + const ctx = await createWslService({ + wslHome, + omitManagedAuth: true, + systemAuth + }) + + expect(ctx.service.prepareForCodexLaunch(WSL_TARGET)).toBe(ctx.runtimeHome) + expect(ctx.store.updateSettings).toHaveBeenCalledWith({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + expect(realFs.readFileSync(ctx.runtimeAuthPath, 'utf-8')).toBe(systemAuth) + }) + + it('still removes the runtime auth mirror when both managed and system auth are proven absent', async () => { + const wslHome = join(testState.userDataDir, 'wsl-home') + const leftover = createCodexAuthJson('stale@example.com', 'acct-stale', 'leftover') + const ctx = await createWslService({ + wslHome, + omitManagedAuth: true, + runtimeAuth: leftover + }) + + ctx.service.syncForCurrentSelection(WSL_TARGET) + + expect(denials.removals).toContain(ctx.runtimeAuthPath) + expect(realFs.existsSync(ctx.runtimeAuthPath)).toBe(false) + }) + + it('skips the poll and refuses launch when the WSL home probe cannot answer', async () => { + const wslHome = join(testState.userDataDir, 'wsl-home') + const ctx = await createWslService({ + wslHome, + getWslHome: () => null + }) + + expect(ctx.service.prepareForRateLimitFetch(WSL_TARGET)).toEqual({ kind: 'skip' }) + expect(() => ctx.service.prepareForCodexLaunch(WSL_TARGET)).toThrow(/could not be reached/) + expect(ctx.store.updateSettings).not.toHaveBeenCalled() + expect(realFs.existsSync(ctx.runtimeAuthPath)).toBe(false) + }) +}) diff --git a/src/main/codex-accounts/wsl-managed-home-presence.test.ts b/src/main/codex-accounts/wsl-managed-home-presence.test.ts new file mode 100644 index 00000000000..253edc90fdd --- /dev/null +++ b/src/main/codex-accounts/wsl-managed-home-presence.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + buildWslManagedHomeCreateArgs, + buildWslManagedHomePresenceArgs, + classifyWslManagedHomeExecError, + WSL_MANAGED_HOME_ABSENT_STATUS, + WSL_MANAGED_HOME_UNREADABLE_STATUS +} from './wsl-managed-home-presence' + +function decodeEncodedWslBashCommand(command: string): string { + const encoded = command.match(/^set -o pipefail; printf %s '([^']+)' \| base64 -d \| bash$/)?.[1] + return encoded ? Buffer.from(encoded, 'base64').toString('utf8') : command +} + +describe('WSL managed home presence probes', () => { + it('probes with --exec and never mkdir/rewrites the marker', () => { + const args = buildWslManagedHomePresenceArgs( + 'Ubuntu', + '/home/alice/.local/share/orca/codex-accounts/account-1/home' + ) + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + expect(args.slice(0, 3)).toEqual(['-d', 'Ubuntu', '--exec']) + expect(script).toContain('stat --') + expect(script).not.toContain('mkdir') + expect(script).not.toContain('.orca-managed-home') + }) + + it('creates the home only in the post-absence write argv', () => { + const args = buildWslManagedHomeCreateArgs( + 'Ubuntu', + '/home/alice/.local/share/orca/codex-accounts/account-1/home', + 'account-1' + ) + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + expect(args.slice(0, 3)).toEqual(['-d', 'Ubuntu', '--exec']) + expect(script).toContain('mkdir -p --') + expect(script).toContain('.orca-managed-home') + expect(script).not.toContain('[ -e ') + expect(script).not.toContain('[ -f ') + }) + + it('treats only exit 2 as absence; every other failure is unreadable', () => { + expect(classifyWslManagedHomeExecError({ status: WSL_MANAGED_HOME_ABSENT_STATUS })).toBe( + 'absent' + ) + expect(classifyWslManagedHomeExecError({ status: WSL_MANAGED_HOME_UNREADABLE_STATUS })).toBe( + 'unreadable' + ) + expect(classifyWslManagedHomeExecError({ status: 1 })).toBe('unreadable') + expect(classifyWslManagedHomeExecError({ code: 'ENOENT' })).toBe('unreadable') + expect(classifyWslManagedHomeExecError(new Error('spawn wsl.exe ENOENT'))).toBe('unreadable') + }) +}) diff --git a/src/main/codex-accounts/wsl-managed-home-presence.ts b/src/main/codex-accounts/wsl-managed-home-presence.ts new file mode 100644 index 00000000000..cbabad839fc --- /dev/null +++ b/src/main/codex-accounts/wsl-managed-home-presence.ts @@ -0,0 +1,50 @@ +import { buildEncodedWslBashCommand, quoteBashString } from '../wsl-bash-command' +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' + +export const WSL_MANAGED_HOME_ABSENT_STATUS = 2 +export const WSL_MANAGED_HOME_UNREADABLE_STATUS = 13 + +export type WslManagedHomePresence = 'present' | 'absent' | 'unreadable' + +export function buildWslManagedHomePresenceArgs(distro: string, linuxPath: string): string[] { + const script = [ + 'set -eu', + `candidate=${quoteBashString(linuxPath)}`, + 'export LC_ALL=C', + // Why: `[ -e ]` is 1 for both missing and EACCES. `stat` plus the C-locale + // "No such file" suffix is the guest-side ENOENT vs everything-else split. + 'if err=$(stat -- "$candidate" 2>&1); then', + ' exit 0', + 'fi', + 'case "$err" in', + ` *"No such file or directory"*) exit ${String(WSL_MANAGED_HOME_ABSENT_STATUS)} ;;`, + ` *) exit ${String(WSL_MANAGED_HOME_UNREADABLE_STATUS)} ;;`, + 'esac' + ].join('\n') + return buildWslExecArgs(distro, ['bash', '-c', buildEncodedWslBashCommand(script)]) +} + +export function buildWslManagedHomeCreateArgs( + distro: string, + linuxPath: string, + accountId: string +): string[] { + const script = [ + 'set -euo pipefail', + `candidate=${quoteBashString(linuxPath)}`, + `expected_marker=${quoteBashString(accountId)}`, + 'mkdir -p -- "$candidate"', + 'printf "%s\\n" "$expected_marker" > "$candidate/.orca-managed-home"' + ].join('\n') + return buildWslExecArgs(distro, ['bash', '-c', buildEncodedWslBashCommand(script)]) +} + +export function classifyWslManagedHomeExecError( + error: unknown +): Exclude { + const status = + error && typeof error === 'object' && 'status' in error && typeof error.status === 'number' + ? error.status + : undefined + return status === WSL_MANAGED_HOME_ABSENT_STATUS ? 'absent' : 'unreadable' +} diff --git a/src/main/wsl-home-resolution.test.ts b/src/main/wsl-home-resolution.test.ts new file mode 100644 index 00000000000..8760bd8fb89 --- /dev/null +++ b/src/main/wsl-home-resolution.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as childProcess from 'node:child_process' + +const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + execFileSyncMock: vi.fn() +})) + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + execFile: execFileMock, + execFileSync: execFileSyncMock + } +}) + +import { _resetWslCachesForTests, getWslHome, resolveWslHome } from './wsl' + +describe('resolveWslHome', () => { + afterEach(() => { + execFileMock.mockReset() + execFileSyncMock.mockReset() + _resetWslCachesForTests() + }) + + it('classifies a wsl.exe failure as unavailable rather than a missing home', () => { + execFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('timeout'), { status: 1 }) + }) + + expect(resolveWslHome('Ubuntu')).toEqual({ + kind: 'unavailable', + error: expect.objectContaining({ message: 'timeout' }) + }) + expect(getWslHome('Ubuntu')).toBeNull() + }) + + it('classifies an empty or relative $HOME as invalid', () => { + execFileSyncMock.mockReturnValue('not-a-home\n') + + expect(resolveWslHome('Ubuntu')).toEqual({ kind: 'invalid' }) + expect(getWslHome('Ubuntu')).toBeNull() + }) + + it('resolves and caches a valid $HOME as a Windows UNC path', () => { + execFileSyncMock.mockReturnValue('/home/alice\n') + + expect(resolveWslHome('Ubuntu')).toEqual({ + kind: 'resolved', + uncPath: '\\\\wsl.localhost\\Ubuntu\\home\\alice' + }) + expect(getWslHome('Ubuntu')).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice') + expect(resolveWslHome('Ubuntu')).toEqual({ + kind: 'resolved', + uncPath: '\\\\wsl.localhost\\Ubuntu\\home\\alice' + }) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/wsl.ts b/src/main/wsl.ts index e09fbe02e4b..ef41861b8ae 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -248,6 +248,51 @@ export function getDefaultWslDistro(): string | null { return listWslDistros()[0] ?? null } +/** + * A WSL `$HOME` probe either answered, answered with a non-absolute path, or + * failed to answer. Callers that would fall through to a different account's + * home must keep `unavailable` distinct from a missing distro. + */ +export type WslHomeResolution = + | { kind: 'resolved'; uncPath: string } + | { kind: 'unavailable'; error: unknown } + | { kind: 'invalid' } + +function rememberResolvedWslHome(distro: string, uncPath: string): WslHomeResolution { + wslHomeCache.set(distro, uncPath) + return { kind: 'resolved', uncPath } +} + +function classifyWslHomeStdout(distro: string, stdout: string): WslHomeResolution { + const home = stdout.trim() + if (!home || !home.startsWith('/')) { + return { kind: 'invalid' } + } + return rememberResolvedWslHome(distro, toWindowsWslPath(home, distro)) +} + +/** + * Typed WSL home lookup. Cached successes only — a failure must be retried, + * never remembered as "this distro has no home". + */ +export function resolveWslHome(distro: string): WslHomeResolution { + const cached = wslHomeCache.get(distro) + if (cached) { + return { kind: 'resolved', uncPath: cached } + } + + try { + const home = execFileSync('wsl.exe', ['-d', distro, '--exec', 'bash', '-c', 'echo $HOME'], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000 + }) + return classifyWslHomeStdout(distro, home) + } catch (error) { + return { kind: 'unavailable', error } + } +} + /** * Get the home directory for a WSL distro, returned as a Windows UNC path. * Result is cached per distro for the process lifetime. @@ -257,27 +302,8 @@ export function getDefaultWslDistro(): string | null { * WSL user's $HOME to compute that path. */ export function getWslHome(distro: string): string | null { - if (wslHomeCache.has(distro)) { - return wslHomeCache.get(distro)! - } - - try { - const home = execFileSync('wsl.exe', ['-d', distro, '--exec', 'bash', '-c', 'echo $HOME'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 - }).trim() - - if (!home || !home.startsWith('/')) { - return null - } - - const uncPath = toWindowsWslPath(home, distro) - wslHomeCache.set(distro, uncPath) - return uncPath - } catch { - return null - } + const resolved = resolveWslHome(distro) + return resolved.kind === 'resolved' ? resolved.uncPath : null } /** Pure cache lookup — never probes. Lets callers that memoize a derived value avoid caching one @@ -286,28 +312,25 @@ export function hasCachedWslHome(distro: string): boolean { return wslHomeCache.has(distro) } -export async function getWslHomeAsync(distro: string): Promise { - if (wslHomeCache.has(distro)) { - return wslHomeCache.get(distro)! +export async function resolveWslHomeAsync(distro: string): Promise { + const cached = wslHomeCache.get(distro) + if (cached) { + return { kind: 'resolved', uncPath: cached } } try { - const home = ( - await execFileUtf8('wsl.exe', ['-d', distro, '--exec', 'bash', '-c', 'echo $HOME']) - ).trim() - - if (!home || !home.startsWith('/')) { - return null - } - - const uncPath = toWindowsWslPath(home, distro) - wslHomeCache.set(distro, uncPath) - return uncPath - } catch { - return null + const home = await execFileUtf8('wsl.exe', ['-d', distro, '--exec', 'bash', '-c', 'echo $HOME']) + return classifyWslHomeStdout(distro, home) + } catch (error) { + return { kind: 'unavailable', error } } } +export async function getWslHomeAsync(distro: string): Promise { + const resolved = await resolveWslHomeAsync(distro) + return resolved.kind === 'resolved' ? resolved.uncPath : null +} + export function _resetWslCachesForTests(): void { wslHomeCache.clear() wslDistroCache = null