codeburn/tests/wsl-cache-eviction-codex.test.ts
Resham Joshi 4cf1885593
windows: discover provider roots inside WSL distros (#1062)
* feat(windows): discover provider roots inside WSL distros

Agents run inside a WSL distro write their history to the distro's Linux
home, exposed to Windows as \\wsl$\<distro>\... and never under the user
profile, so a Windows-only scan reported zero sessions for them.

src/wsl.ts lists distros through %SystemRoot%\System32\wsl.exe (absolute,
so nothing dropped next to the CLI can impersonate it) with a 3s timeout,
decodes its UTF-16LE output, drops container-runtime distros, and
enumerates each distro's home/* and root. Running distros only by
default: touching \\wsl$ boots a stopped distro. CODEBURN_WSL=all|off
opts in or out. Inert off win32 and memoized per process.

claude and codex append <wslHome>/.claude and <wslHome>/.codex to their
existing root lists, so multi-root discovery, probeRoots() and doctor
cover them unchanged. Roots are additive, never an override.

CODEBURN_WSL stays out of PROVIDER_ENV_VARS: cache entries are keyed by
source path, so it can only add or drop paths, never make a kept entry
stale, while declaring it would force a re-parse on every toggle.

Fingerprints drop dev/ino for \\wsl$ paths in both caches. The 9P share
synthesizes them per mount, so keying on them would re-parse every WSL
session on every run; mtime+size still catches edits and appends.

Refs #1059

* test(windows): cover WSL root discovery and the 9P carve-outs

UTF-16 output parsing, utility-distro filtering, CODEBURN_WSL modes, UNC
classification, claude/codex root injection and discovery, probeRoots,
fingerprint reconciliation with zeroed dev/ino, and the win32 guard that
keeps a Linux cwd from being walked on Windows.

Refs #1059

* docs(windows): document WSL discovery and CODEBURN_WSL

Refs #1059

* fix(windows): harden WSL discovery from adversarial review

Probe \\wsl$\ before \\wsl.localhost\. The older spelling works on every
WSL build; probing the newer one first sends builds without it through
MUP, SMB and DNS resolution for a host named "wsl.localhost" — seconds
of stall per distro, every process.

Only accept lines shaped like a distro name. With nothing installed,
wsl.exe prints prose on stdout whose lines otherwise read as distro
names and get probed over UNC. A name must be a single token with none
of the characters Windows bans from a path component and no trailing
period, which discards that message in any UI language.

Do not evict a \\wsl$ cache entry for being undiscovered, and serve it
in write mode too. A stopped distro drops its whole root out of
discovery; treating that as a deletion re-parsed everything over 9P on
the next start and made totals drop while the read-only serve path still
showed them. The exemption is scoped to isWslUncPath — an ordinary path
whose file is gone is still evicted.

Also: getCachedCodexProject was the one fingerprint site still keying on
dev/ino for WSL paths; doctor now prints why it probed no WSL roots; and
a distro contributing several homes labels them per user rather than
letting makeUniqueLabels number them.

Refs #1059

* fix(windows): re-validate running WSL distros on a TTL before UNC access

wslHomes() memoized the running-distro homes for the process lifetime. In
long-lived serve/menubar use a distro cached as running could be shut down
and the next refresh would still touch its \\wsl$ UNC path — which can hang
win32 fs calls — while a distro started after an empty discovery stayed
invisible until restart.

Discovery now caches for a 60s TTL only; on expiry the wsl.exe -l --running
probe (timeout-bounded) re-validates the running list before any UNC path
is touched, and a failed probe fails closed to native-only discovery rather
than serving stale homes. Covers both shutdown and later-start transitions.

Addresses the blocking review finding on PR #1062.

* fix(windows): harden WSL cache reconciliation

* fix(windows): bound WSL orphan refresh to the discovery TTL

refreshWslHomes re-spawned wsl.exe on every full parse for a stopped-but-
cached distro, ignoring the 60s TTL — up to ~3s twice per parse in the
resident serve/menubar. Reuse the TTL-bounded wslHomes() probe instead; the
same TTL already reflects the only transition the orphan check must catch,
offline->running.

* test(windows): skip on-disk POSIX-worktree walk case on real win32

The fixture builds a worktree under tmpdir() and treats it as a POSIX cwd the
win32 guard should reject. On a real Windows runner tmpdir() is a C:\ path, so
the guard rightly walks it and canonicalizes to the main repo, failing the
assertion. The case is only constructible on a POSIX host (mocked win32); the
literal /home/... test already covers the guard on Windows.

---------

Co-authored-by: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com>
2026-09-19 12:33:29 -07:00

185 lines
7.2 KiB
TypeScript

// Same carve-out as wsl-cache-eviction.test.ts, but through
// parseProviderSources (every non-Claude provider) rather than
// scanProjectDirs. Separate file because the codex provider captures
// CODEX_HOME when its module is first imported, so the env has to be in place
// before anything pulls src/parser.ts in — hence the dynamic imports below.
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync } from 'fs'
import { readFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import type { DateRange } from '../src/types.js'
const tmpDir = mkdtempSync(join(tmpdir(), 'wsl-evict-codex-'))
const savedCodexHome = process.env['CODEX_HOME']
process.env['CODEX_HOME'] = join(tmpDir, 'codex')
const { CACHE_VERSION, computeEnvFingerprint, loadCache, saveCache } = await import('../src/session-cache.js')
const { clearSessionCache, parseAllSessions } = await import('../src/parser.js')
const {
clearCodexMemCaches,
codexCacheFileName,
flushCodexCache,
withCodexCacheDirectory,
writeCachedCodexResults,
} = await import('../src/codex-cache.js')
const { setWslHomes } = await import('../src/wsl.js')
type CachedFile = Awaited<ReturnType<typeof loadCache>>['providers'][string]['files'][string]
type SessionCache = Awaited<ReturnType<typeof loadCache>>
const WSL_PATH = '\\\\wsl$\\Ubuntu\\home\\me\\.codex\\sessions\\2099\\05\\01\\rollout-2099-05-01T10-00-00-wsl.jsonl'
const GONE_PATH = '/tmp/codeburn-deleted-forever/rollout-2099-05-01T10-00-00-gone.jsonl'
const ENV_KEYS = ['CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEBURN_DESKTOP_SESSIONS_DIR', 'CODEBURN_WSL'] as const
let saved: Record<string, string | undefined>
function cachedFile(sessionId: string, project: string, cost: number): CachedFile {
return {
fingerprint: { dev: 0, ino: 0, mtimeMs: 1000, sizeBytes: 100 },
mcpInventory: [],
turns: [{
timestamp: '2099-05-01T10:00:00.000Z',
sessionId,
userMessage: 'hi',
calls: [{
provider: 'codex',
model: 'gpt-5.3-codex',
usage: {
inputTokens: 100, outputTokens: 50, cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, cacheCreationOneHourTokens: 0,
},
costUSD: cost,
speed: 'standard',
timestamp: '2099-05-01T10:00:00.000Z',
tools: [], bashCommands: [], skills: [], subagentTypes: [],
deduplicationKey: `dedup-${sessionId}`,
project,
projectPath: `/home/me/${project}`,
}],
}],
}
}
function dayRange(day: string): DateRange {
return { start: new Date(`${day}T00:00:00.000Z`), end: new Date(`${day}T23:59:59.999Z`) }
}
beforeEach(async () => {
saved = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
process.env['CLAUDE_CONFIG_DIR'] = join(tmpDir, 'no-claude')
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'no-desktop')
delete process.env['CLAUDE_CONFIG_DIRS']
delete process.env['CODEBURN_WSL']
setWslHomes([])
clearSessionCache()
clearCodexMemCaches()
// One discovered rollout: the eviction pass is gated on sources.length > 0.
const dayDir = join(tmpDir, 'codex', 'sessions', '2099', '05', '01')
mkdirSync(dayDir, { recursive: true })
writeFileSync(join(dayDir, 'rollout-2099-05-01T09-00-00-live.jsonl'), JSON.stringify({
type: 'session_meta',
timestamp: '2099-05-01T09:00:00Z',
payload: { cwd: '/home/me/live', originator: 'codex-cli', session_id: 'sess-live', model: 'gpt-5.3-codex' },
}) + '\n')
const cache: SessionCache = {
version: CACHE_VERSION,
complete: true,
providers: {
codex: {
envFingerprint: computeEnvFingerprint('codex'),
files: {
[WSL_PATH]: cachedFile('sess-wsl', 'wsl-proj', 4.25),
[GONE_PATH]: cachedFile('sess-gone', 'gone-proj', 9.5),
},
},
},
}
await saveCache(cache)
clearSessionCache()
})
afterEach(async () => {
setWslHomes(undefined)
clearSessionCache()
clearCodexMemCaches()
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key]
else process.env[key] = saved[key]!
}
await rm(join(tmpDir, 'cache'), { recursive: true, force: true })
})
afterEach(async () => {
if (savedCodexHome === undefined) delete process.env['CODEX_HOME']
else process.env['CODEX_HOME'] = savedCodexHome
})
describe('parseProviderSources: a stopped WSL distro is not a deletion (#1059)', () => {
it('retains and serves the \\\\wsl$ entry while evicting a genuinely deleted one', async () => {
const projects = await parseAllSessions(dayRange('2099-05-01'), 'codex')
expect(projects.map(p => p.project)).toContain('wsl-proj')
expect(projects.filter(p => p.project === 'wsl-proj')).toHaveLength(1)
expect(projects.find(p => p.project === 'wsl-proj')!.totalCostUSD).toBeCloseTo(4.25, 5)
expect(projects.map(p => p.project)).not.toContain('gone-proj')
clearSessionCache()
const after = await loadCache()
const files = Object.keys(after.providers['codex']?.files ?? {})
expect(files).toContain(WSL_PATH)
expect(files).not.toContain(GONE_PATH)
})
it('evicts a WSL orphan when its home is still active', async () => {
const cacheDir = process.env['CODEBURN_CACHE_DIR']!
await withCodexCacheDirectory(cacheDir, async () => {
await writeCachedCodexResults(
WSL_PATH,
'wsl-proj',
[],
{ dev: 0, ino: 0, mtimeMs: 1000, sizeBytes: 100 },
)
await flushCodexCache()
})
// Exercise the cold-process path too: the explicit eviction must remove a
// valid row loaded from disk, not rely on the seeding process's memo.
clearCodexMemCaches()
setWslHomes(['\\\\wsl$\\Ubuntu\\home\\me'])
await rm(join(tmpDir, 'codex', 'sessions'), { recursive: true, force: true })
const projects = await parseAllSessions(dayRange('2099-05-01'), 'codex')
expect(projects.map(p => p.project)).not.toContain('wsl-proj')
clearSessionCache()
const after = await loadCache()
const files = Object.keys(after.providers['codex']?.files ?? {})
expect(files).not.toContain(WSL_PATH)
const rawCodexCache = JSON.parse(await readFile(join(cacheDir, codexCacheFileName()), 'utf8'))
expect(rawCodexCache.files[WSL_PATH]).toBeUndefined()
})
it('serves WSL-only cached usage when no Codex source is discoverable', async () => {
await rm(join(tmpDir, 'codex', 'sessions'), { recursive: true, force: true })
const projects = await parseAllSessions(dayRange('2099-05-01'), 'codex')
expect(projects.map(p => p.project)).toContain('wsl-proj')
expect(projects.find(p => p.project === 'wsl-proj')!.totalCostUSD).toBeCloseTo(4.25, 5)
})
it('keeps historical WSL usage while off disables discovery and UNC access', async () => {
process.env['CODEBURN_WSL'] = 'off'
await rm(join(tmpDir, 'codex', 'sessions'), { recursive: true, force: true })
const projects = await parseAllSessions(dayRange('2099-05-01'), 'codex')
expect(projects.map(p => p.project)).toContain('wsl-proj')
clearSessionCache()
const after = await loadCache()
expect(Object.keys(after.providers['codex']?.files ?? {})).toContain(WSL_PATH)
})
})