mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-28 18:13:16 +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>
151 lines
5.7 KiB
TypeScript
151 lines
5.7 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { tmpdir } from 'os'
|
|
|
|
import { CACHE_VERSION, computeEnvFingerprint, loadCache, saveCache, type CachedFile, type SessionCache } from '../src/session-cache.js'
|
|
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
|
|
import { setWslHomes } from '../src/wsl.js'
|
|
import type { DateRange } from '../src/types.js'
|
|
|
|
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>
|
|
let tmpDir: string
|
|
|
|
// A transcript inside a WSL distro, and one whose file is genuinely deleted.
|
|
// Neither is discoverable, which is exactly the point: a stopped distro must
|
|
// not read as a deletion.
|
|
const WSL_PATH = '\\\\wsl$\\Ubuntu\\home\\me\\.claude\\projects\\-home-me-proj\\wsl-session.jsonl'
|
|
const GONE_PATH = '/tmp/codeburn-deleted-forever/gone-session.jsonl'
|
|
|
|
function cachedFile(sessionId: string, project: string, cost: number): CachedFile {
|
|
return {
|
|
fingerprint: { dev: 0, ino: 0, mtimeMs: 1000, sizeBytes: 100 },
|
|
mcpInventory: [],
|
|
canonicalProjectName: project,
|
|
turns: [{
|
|
timestamp: '2099-05-01T10:00:00.000Z',
|
|
sessionId,
|
|
userMessage: 'hi',
|
|
calls: [{
|
|
provider: 'claude',
|
|
model: 'claude-sonnet-4-5',
|
|
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]]))
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'wsl-evict-'))
|
|
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
|
|
process.env['CLAUDE_CONFIG_DIR'] = join(tmpDir, 'claude')
|
|
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'no-desktop')
|
|
delete process.env['CLAUDE_CONFIG_DIRS']
|
|
delete process.env['CODEBURN_WSL']
|
|
setWslHomes([])
|
|
clearSessionCache()
|
|
// One real, discovered transcript: the eviction pass is gated on the run
|
|
// having actually scanned something (a pruned-to-nothing config dir keeps
|
|
// its orphans deliberately).
|
|
const realDir = join(tmpDir, 'claude', 'projects', '-home-me-live')
|
|
await mkdir(realDir, { recursive: true })
|
|
await writeFile(join(realDir, 'live-session.jsonl'), JSON.stringify({
|
|
type: 'assistant',
|
|
sessionId: 'sess-live',
|
|
timestamp: '2099-05-01T09:00:00.000Z',
|
|
cwd: '/home/me/live',
|
|
message: {
|
|
id: 'msg-live',
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: 'claude-sonnet-4-5',
|
|
content: [],
|
|
usage: { input_tokens: 10, output_tokens: 5 },
|
|
},
|
|
}) + '\n')
|
|
|
|
const cache: SessionCache = {
|
|
version: CACHE_VERSION,
|
|
complete: true,
|
|
providers: {
|
|
claude: {
|
|
envFingerprint: computeEnvFingerprint('claude'),
|
|
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()
|
|
for (const key of ENV_KEYS) {
|
|
if (saved[key] === undefined) delete process.env[key]
|
|
else process.env[key] = saved[key]!
|
|
}
|
|
await rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
describe('a stopped WSL distro is an offline root, not a deleted file (#1059)', () => {
|
|
it('retains and serves the \\\\wsl$ entry while evicting a genuinely deleted one', async () => {
|
|
const projects = await parseAllSessions(dayRange('2099-05-01'), 'claude')
|
|
|
|
// Served: spend does not vanish for the length of a `wsl --shutdown`.
|
|
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)
|
|
// Not widened: a normal path whose file is gone is still pruned.
|
|
expect(projects.map(p => p.project)).not.toContain('gone-proj')
|
|
|
|
clearSessionCache()
|
|
const after = await loadCache()
|
|
const files = Object.keys(after.providers['claude']?.files ?? {})
|
|
expect(files).toContain(WSL_PATH)
|
|
expect(files).not.toContain(GONE_PATH)
|
|
})
|
|
|
|
it('evicts a WSL orphan when its home is still active', async () => {
|
|
setWslHomes(['\\\\wsl$\\Ubuntu\\home\\me'])
|
|
await rm(join(tmpDir, 'claude'), { recursive: true, force: true })
|
|
|
|
const projects = await parseAllSessions(dayRange('2099-05-01'), 'claude')
|
|
expect(projects.map(p => p.project)).not.toContain('wsl-proj')
|
|
|
|
clearSessionCache()
|
|
const after = await loadCache()
|
|
const files = Object.keys(after.providers['claude']?.files ?? {})
|
|
expect(files).not.toContain(WSL_PATH)
|
|
})
|
|
|
|
it('keeps historical WSL usage while off disables discovery and UNC access', async () => {
|
|
process.env['CODEBURN_WSL'] = 'off'
|
|
|
|
const projects = await parseAllSessions(dayRange('2099-05-01'), 'claude')
|
|
expect(projects.map(p => p.project)).toContain('wsl-proj')
|
|
|
|
clearSessionCache()
|
|
const after = await loadCache()
|
|
expect(Object.keys(after.providers['claude']?.files ?? {})).toContain(WSL_PATH)
|
|
})
|
|
})
|