mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
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:
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user