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.
This commit is contained in:
Brennan Benson
2026-08-20 18:17:49 -07:00
parent 012e9f410c
commit 38fea1d435
9 changed files with 998 additions and 151 deletions
+193 -95
View File
@@ -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<string, string | null>()
private readonly wslRuntimeHomePathByDistro = new Map<string, string>()
private skipNextReadBackForAccountId: string | null = null
private readonly skipNextReadBackForAccountIdByDistro = new Map<string, string>()
// 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] }
@@ -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`
}
+23 -19
View File
@@ -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 }
)
}
@@ -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 })
}
})
})
@@ -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<string>(),
reads: new Map<string, number>(),
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<typeof NodeFs>()
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<string, unknown> = {
...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<typeof actual.rmSync>))
}, 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<typeof import('node:os')>('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<typeof NodeFs>('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)
})
})
@@ -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')
})
})
@@ -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<WslManagedHomePresence, 'present'> {
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'
}
+60
View File
@@ -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<typeof childProcess>()
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)
})
})
+60 -37
View File
@@ -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<string | null> {
if (wslHomeCache.has(distro)) {
return wslHomeCache.get(distro)!
export async function resolveWslHomeAsync(distro: string): Promise<WslHomeResolution> {
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<string | null> {
const resolved = await resolveWslHomeAsync(distro)
return resolved.kind === 'resolved' ? resolved.uncPath : null
}
export function _resetWslCachesForTests(): void {
wslHomeCache.clear()
wslDistroCache = null