mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-11-06 06:20:53 +00:00
* 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>
185 lines
7.2 KiB
TypeScript
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)
|
|
})
|
|
})
|