mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-27 09:11:45 +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>
246 lines
9.4 KiB
TypeScript
246 lines
9.4 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
|
|
// discoverWslHomes is win32-only and shells out to wsl.exe over a 9P share.
|
|
// Mock both so the spawn contract itself is asserted on macOS/Linux CI.
|
|
const execFileSync = vi.fn()
|
|
const readdirSync = vi.fn()
|
|
const existsSync = vi.fn()
|
|
|
|
vi.mock('child_process', () => ({ execFileSync: (...a: unknown[]) => execFileSync(...a) }))
|
|
vi.mock('fs', () => ({
|
|
readdirSync: (...a: unknown[]) => readdirSync(...a),
|
|
existsSync: (...a: unknown[]) => existsSync(...a),
|
|
}))
|
|
|
|
const { setWslHomes, wslDoctorNote, wslHomes, refreshWslHomes } = await import('../src/wsl.js')
|
|
|
|
function utf16(lines: string[]): Buffer {
|
|
return Buffer.from(lines.join('\r\n') + '\r\n', 'utf16le')
|
|
}
|
|
|
|
function dirent(name: string) {
|
|
return { name, isDirectory: () => true }
|
|
}
|
|
|
|
let platform: PropertyDescriptor
|
|
|
|
beforeEach(() => {
|
|
platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
|
|
process.env['SystemRoot'] = 'C:\\Windows'
|
|
delete process.env['CODEBURN_WSL']
|
|
setWslHomes(undefined)
|
|
execFileSync.mockReset()
|
|
readdirSync.mockReset()
|
|
existsSync.mockReset()
|
|
existsSync.mockReturnValue(false)
|
|
})
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(process, 'platform', platform)
|
|
delete process.env['CODEBURN_WSL']
|
|
setWslHomes(undefined)
|
|
})
|
|
|
|
describe('discoverWslHomes', () => {
|
|
it('runs wsl.exe out of System32 with --list --quiet --running by default', () => {
|
|
execFileSync.mockReturnValue(utf16(['Ubuntu']))
|
|
readdirSync.mockReturnValue([dirent('alice')])
|
|
|
|
wslHomes()
|
|
|
|
expect(execFileSync).toHaveBeenCalledTimes(1)
|
|
const [exe, args, opts] = execFileSync.mock.calls[0]!
|
|
expect(exe).toBe('C:\\Windows\\System32\\wsl.exe')
|
|
expect(args).toEqual(['--list', '--quiet', '--running'])
|
|
expect(opts).toMatchObject({ timeout: 3000, windowsHide: true })
|
|
})
|
|
|
|
it('drops --running when CODEBURN_WSL=all', () => {
|
|
process.env['CODEBURN_WSL'] = 'all'
|
|
execFileSync.mockReturnValue(utf16(['Ubuntu']))
|
|
readdirSync.mockReturnValue([])
|
|
|
|
wslHomes()
|
|
|
|
expect(execFileSync.mock.calls[0]![1]).toEqual(['--list', '--quiet'])
|
|
})
|
|
|
|
it('re-probes immediately when the mode changes inside a long-lived process', () => {
|
|
execFileSync.mockReturnValue(utf16(['Ubuntu']))
|
|
readdirSync.mockReturnValue([])
|
|
wslHomes(1_000)
|
|
|
|
process.env['CODEBURN_WSL'] = 'all'
|
|
wslHomes(1_001)
|
|
|
|
expect(execFileSync).toHaveBeenCalledTimes(2)
|
|
expect(execFileSync.mock.calls[1]![1]).toEqual(['--list', '--quiet'])
|
|
})
|
|
|
|
it('honors off immediately instead of returning a cached running home', () => {
|
|
execFileSync.mockReturnValue(utf16(['Ubuntu']))
|
|
readdirSync.mockImplementation((p: string) => (p === '\\\\wsl$\\Ubuntu\\home' ? [dirent('alice')] : []))
|
|
expect(wslHomes(1_000)).toEqual(['\\\\wsl$\\Ubuntu\\home\\alice'])
|
|
|
|
process.env['CODEBURN_WSL'] = 'off'
|
|
expect(wslHomes(1_001)).toEqual([])
|
|
expect(execFileSync).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('never spawns anything when CODEBURN_WSL=off', () => {
|
|
process.env['CODEBURN_WSL'] = 'off'
|
|
expect(wslHomes()).toEqual([])
|
|
expect(execFileSync).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('never spawns anything off win32', () => {
|
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
|
|
expect(wslHomes()).toEqual([])
|
|
expect(execFileSync).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('enumerates home/* plus root under the \\\\wsl$ prefix', () => {
|
|
execFileSync.mockReturnValue(utf16(['Ubuntu']))
|
|
readdirSync.mockImplementation((p: string) => (p === '\\\\wsl$\\Ubuntu\\home' ? [dirent('alice'), dirent('bob')] : []))
|
|
existsSync.mockImplementation((p: string) => p === '\\\\wsl$\\Ubuntu\\root')
|
|
|
|
expect(wslHomes()).toEqual([
|
|
'\\\\wsl$\\Ubuntu\\home\\alice',
|
|
'\\\\wsl$\\Ubuntu\\home\\bob',
|
|
'\\\\wsl$\\Ubuntu\\root',
|
|
])
|
|
})
|
|
|
|
it('falls back to \\\\wsl.localhost only when \\\\wsl$ yields nothing, and stops at the first hit', () => {
|
|
execFileSync.mockReturnValue(utf16(['Ubuntu', 'Debian']))
|
|
readdirSync.mockImplementation((p: string) => {
|
|
if (p === '\\\\wsl$\\Ubuntu\\home') return [dirent('alice')]
|
|
if (p === '\\\\wsl.localhost\\Debian\\home') return [dirent('bob')]
|
|
return []
|
|
})
|
|
|
|
expect(wslHomes()).toEqual(['\\\\wsl$\\Ubuntu\\home\\alice', '\\\\wsl.localhost\\Debian\\home\\bob'])
|
|
// Ubuntu resolved on the first prefix, so the slow spelling is never tried for it.
|
|
expect(readdirSync.mock.calls.map(c => c[0])).not.toContain('\\\\wsl.localhost\\Ubuntu\\home')
|
|
})
|
|
|
|
it('probes nothing when wsl.exe reports no installed distributions', () => {
|
|
execFileSync.mockReturnValue(utf16([
|
|
'Windows Subsystem for Linux has no installed distributions.',
|
|
'Use \'wsl.exe --list --online\' to list available distributions',
|
|
'and \'wsl.exe --install <Distro>\' to install.',
|
|
'',
|
|
'Distributions can also be installed by visiting the Microsoft Store:',
|
|
'https://aka.ms/wslstore',
|
|
]))
|
|
|
|
expect(wslHomes()).toEqual([])
|
|
expect(readdirSync).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns nothing when wsl.exe is missing or times out', () => {
|
|
execFileSync.mockImplementation(() => { throw new Error('ENOENT') })
|
|
expect(wslHomes()).toEqual([])
|
|
})
|
|
|
|
it('memoizes within the TTL so repeated discovery never respawns', () => {
|
|
execFileSync.mockReturnValue(utf16(['Ubuntu']))
|
|
readdirSync.mockReturnValue([])
|
|
wslHomes(1_000)
|
|
wslHomes(1_000 + 59_999)
|
|
expect(execFileSync).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('re-probes after the TTL when a distro shuts down, and fails closed', () => {
|
|
execFileSync.mockReturnValue(utf16(['Ubuntu']))
|
|
readdirSync.mockImplementation((p: string) => (p === '\\\\wsl$\\Ubuntu\\home' ? [dirent('alice')] : []))
|
|
expect(wslHomes(1_000)).toEqual(['\\\\wsl$\\Ubuntu\\home\\alice'])
|
|
|
|
// The distro is gone at the next refresh: the re-probe throws/times out.
|
|
// The stale home must not be served, and no UNC path may be touched.
|
|
execFileSync.mockImplementation(() => { throw new Error('timed out') })
|
|
readdirSync.mockClear()
|
|
expect(wslHomes(1_000 + 60_000)).toEqual([])
|
|
expect(readdirSync).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('drops a shut-down distro from the refreshed result', () => {
|
|
execFileSync.mockReturnValue(utf16(['Ubuntu']))
|
|
readdirSync.mockImplementation((p: string) => (p === '\\\\wsl$\\Ubuntu\\home' ? [dirent('alice')] : []))
|
|
expect(wslHomes(1_000)).toEqual(['\\\\wsl$\\Ubuntu\\home\\alice'])
|
|
|
|
execFileSync.mockReturnValue(utf16([]))
|
|
readdirSync.mockClear()
|
|
expect(wslHomes(1_000 + 60_000)).toEqual([])
|
|
expect(readdirSync).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('discovers a distro started after an initial empty result', () => {
|
|
execFileSync.mockReturnValue(utf16([]))
|
|
expect(wslHomes(1_000)).toEqual([])
|
|
|
|
execFileSync.mockReturnValue(utf16(['Ubuntu']))
|
|
readdirSync.mockImplementation((p: string) => (p === '\\\\wsl$\\Ubuntu\\home' ? [dirent('alice')] : []))
|
|
expect(wslHomes(1_000 + 60_000)).toEqual(['\\\\wsl$\\Ubuntu\\home\\alice'])
|
|
expect(execFileSync).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('pinned test homes never expire', () => {
|
|
setWslHomes(['\\\\wsl$\\Ubuntu\\home\\alice'])
|
|
expect(wslHomes(1_000)).toEqual(['\\\\wsl$\\Ubuntu\\home\\alice'])
|
|
expect(wslHomes(1_000 + 3_600_000)).toEqual(['\\\\wsl$\\Ubuntu\\home\\alice'])
|
|
expect(execFileSync).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('orphan refresh reuses the cached probe within the TTL, so an offline distro does not respawn wsl.exe every parse', () => {
|
|
execFileSync.mockReturnValue(utf16(['Ubuntu']))
|
|
readdirSync.mockImplementation((p: string) => (p === '\\\\wsl$\\Ubuntu\\home' ? [dirent('alice')] : []))
|
|
// A parse discovers the running set once and caches it.
|
|
wslHomes(1_000)
|
|
expect(execFileSync).toHaveBeenCalledTimes(1)
|
|
|
|
// The orphan check (a stopped distro's cached path is still classed offline)
|
|
// must not re-spawn within the TTL — the bug this bounds.
|
|
refreshWslHomes(1_000)
|
|
refreshWslHomes(1_000 + 59_999)
|
|
expect(execFileSync).toHaveBeenCalledTimes(1)
|
|
expect(refreshWslHomes(1_000 + 59_999)).toEqual(['\\\\wsl$\\Ubuntu\\home\\alice'])
|
|
|
|
// Past the TTL it re-probes once, catching an offline->running transition.
|
|
refreshWslHomes(1_000 + 60_000)
|
|
expect(execFileSync).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
|
|
describe('doctor note', () => {
|
|
it('names the opt-out when CODEBURN_WSL=off', () => {
|
|
process.env['CODEBURN_WSL'] = 'off'
|
|
expect(wslDoctorNote()).toContain('CODEBURN_WSL=off')
|
|
expect(execFileSync).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('explains a zero result when no distro is running', () => {
|
|
execFileSync.mockImplementation(() => { throw new Error('ENOENT') })
|
|
expect(wslDoctorNote()).toContain('no running distro')
|
|
})
|
|
|
|
it('says nothing when WSL roots were found', () => {
|
|
setWslHomes(['\\\\wsl$\\Ubuntu\\home\\alice'])
|
|
expect(wslDoctorNote()).toBeUndefined()
|
|
})
|
|
|
|
it('says nothing off win32', () => {
|
|
expect(wslDoctorNote('darwin')).toBeUndefined()
|
|
})
|
|
|
|
it('reaches the rendered doctor output', async () => {
|
|
process.env['CODEBURN_WSL'] = 'off'
|
|
const { renderDoctorTable } = await import('../src/doctor.js')
|
|
const table = renderDoctorTable(
|
|
{ generatedAt: '2026-01-01T00:00:00.000Z', providers: [], wslNote: 'WSL scan disabled by CODEBURN_WSL=off.' },
|
|
{ color: false },
|
|
)
|
|
expect(table).toContain('WSL scan disabled by CODEBURN_WSL=off.')
|
|
})
|
|
})
|