Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixed
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)
- **Nine providers served silently stale numbers after you pointed their env override at a different profile or root.** Kiro, Grok, Kimi, Mux, Mistral Vibe, Zerostack, Codebuff, Goose and Crush each honor an env var that relocates where discovery looks, but the var was never declared in the provider env fingerprint, so the cache section survived the change and kept reporting sessions parsed from the old root — with no diagnostic anywhere. The fix declares those vars, the adjacent OS-set path variables that resolve a discovery root for Claude, IBM Bob, Open Design and Kilo Code on Windows and Linux, Cursor's parse-budget override, and the Vercel AI Gateway credential — which must invalidate the fingerprint because a read-only refresh serves the cached report and would otherwise keep reporting the previous account's usage after a swap. Your next run re-parses the fourteen file-backed providers whose declarations changed — the nine above plus Claude, Cursor, Open Design, IBM Bob and Kilo Code — once, and only once; the Vercel AI Gateway declaration is a read-only-path correction, not a migration (its report is re-fetched on every writable run anyway); Copilot is deliberately NOT included, because declaring its overrides would force a re-parse that can drop OTel history only the cache still holds; `codeburn doctor` names deliberate overrides including the XDG_* vars, never the Windows ambient APPDATA / LOCALAPPDATA, and redacts credential values. (#920)

## 0.9.19 - 2026-07-20

Expand Down
40 changes: 35 additions & 5 deletions packages/cli/src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,19 +99,49 @@ const PARSE_CALL_CAP = 500
// (readdir/stat only) still runs, so session counts stay meaningful.
const PARSE_SPAWNS = new Set(['antigravity'])

// CodeBurn's own cache location: listed in PROVIDER_ENV_VARS for cache
// fingerprinting, but it is not a discovery path, so it must never be blamed
// in a NOTHING FOUND hint.
const NON_DISCOVERY_ENV_VARS = new Set(['CODEBURN_CACHE_DIR'])
// Vars listed in PROVIDER_ENV_VARS for cache fingerprinting that are NOT
// discovery paths: a change to them can never explain "nothing was
// discovered", so they must never be blamed in a NOTHING FOUND hint.
// - CODEBURN_CACHE_DIR: CodeBurn's own cache location — where the cache
// file lives, not where sessions are discovered.
// - CODEBURN_CURSOR_MAX_BUBBLES: caps how many bubbles Cursor parses
// (src/providers/cursor.ts:530) — a parse budget, not a discovery root.
// - KIMI_MODEL_NAME: renames the model attributed to Kimi sessions
// (src/providers/kimi.ts:125) — attribution, not discovery.
// All three still appear in the Details block; only the verdict's blame line
// is cleared of them.
const NON_DISCOVERY_ENV_VARS = new Set(['CODEBURN_CACHE_DIR', 'CODEBURN_CURSOR_MAX_BUBBLES', 'KIMI_MODEL_NAME'])

// Ambient platform paths (set by the OS or desktop session for everyone), not
// deliberate user overrides: Windows sets APPDATA and LOCALAPPDATA for every
// process, so they carry no user intent and doctor must not name them as an
// override. The XDG_* vars are the opposite — they are opt-in on Linux, so a
// set value IS a deliberate user override and stays visible: with XDG_DATA_HOME
// pointed at a missing dir, blaming the install instead of the override
// (the pre-#920 behavior) told the user the tool was missing when they had
// deliberately relocated it. All of them are still fingerprinted — a change
// to any of them does move the discovery root, so the cache must invalidate —
// and the probed paths doctor already prints show exactly where CodeBurn
// looked.
const AMBIENT_ENV_VARS = new Set(['APPDATA', 'LOCALAPPDATA'])

// Credential names whose VALUE must never be printed: knowing whether the
// credential is set is a useful diagnostic, but the value is a live secret.
// Redact at collect time so BOTH the text render and the JSON report are
// covered, and doctor can never leak a key into a bug report or a paste.
const SECRET_ENV_VARS = new Set(['AI_GATEWAY_API_KEY', 'VERCEL_OIDC_TOKEN'])

// ── Collect (pure, testable) ─────────────────────────────────────────────

function collectEnvOverrides(providerName: string): DoctorEnvOverride[] {
const vars = PROVIDER_ENV_VARS[providerName] ?? []
const out: DoctorEnvOverride[] = []
for (const name of vars) {
if (AMBIENT_ENV_VARS.has(name)) continue
const value = process.env[name]
if (value !== undefined && value !== '') out.push({ name, value })
if (value !== undefined && value !== '') {
out.push(SECRET_ENV_VARS.has(name) ? { name, value: '<set>' } : { name, value })
}
}
return out
}
Expand Down
46 changes: 41 additions & 5 deletions packages/cli/src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,24 +168,60 @@ const CACHE_FILE = `session-cache.v${CACHE_VERSION}.json`
const LEGACY_CACHE_FILE = 'session-cache.json'
const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000

// Env vars that change what a provider discovers or how its sessions parse.
// computeEnvFingerprint hashes exactly these to decide when a provider's cache
// section is stale; a var read by the provider but missing here means changing
// it serves the old section silently, reporting nothing from the new root.
// Two reads in src/providers/ are deliberately absent: CODEBURN_VERBOSE
// (opencode.ts:151, kilo-code.ts:94) only changes logging verbosity, never
// parsed output.
//
// Copilot is deliberately NOT declared here. Declaring any CODEBURN_COPILOT_*
// var would change its fingerprint, and on a fingerprint change
// getOrCreateProviderSection (src/parser.ts:1270) keeps only the cached
// entries whose source path no longer exists — but copilot's OTel discovery
// returns one source per DB file ({ path: dbPath }, src/providers/copilot.ts:431)
// and that DB keeps existing, so its cached entry would be dropped and
// re-parsed, destroying conversations Copilot has since pruned from the DB
// that only the cache still holds (see DURABLE_PROVIDER_NAMES below). Do not
// "complete" the map for copilot until the durable carry-forward learns to
// merge instead of drop.
export const PROVIDER_ENV_VARS: Record<string, string[]> = {
claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR'],
claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR', 'CODEBURN_DESKTOP_SESSIONS_DIR', 'APPDATA', 'LOCALAPPDATA'],
codebuff: ['CODEBUFF_DATA_DIR'],
codewhale: ['CODEWHALE_HOME'],
codex: ['CODEX_HOME'],
hermes: ['HERMES_HOME'],
'lingtai-tui': ['LINGTAI_HOME', 'LINGTAI_TUI_HOME', 'LINGTAI_TUI_GLOBAL_DIR'],
droid: ['FACTORY_DIR'],
cursor: ['XDG_DATA_HOME'],
cursor: ['CODEBURN_CURSOR_MAX_BUBBLES'],
// XDG_DATA_HOME is stale here (cursor-agent never reads it) but deliberately
// kept: removing it would force a re-parse to fix nothing.
'cursor-agent': ['XDG_DATA_HOME'],
'open-design': ['CODEBURN_OPEN_DESIGN_DIR', 'APPDATA'],
opencode: ['XDG_DATA_HOME', 'OPENCODE_DATA_DIR', 'OPENCODE_DB_PREFIX'],
goose: ['XDG_DATA_HOME'],
crush: ['XDG_DATA_HOME'],
goose: ['XDG_DATA_HOME', 'GOOSE_PATH_ROOT'],
grok: ['GROK_HOME'],
crush: ['XDG_DATA_HOME', 'CRUSH_GLOBAL_DATA', 'LOCALAPPDATA'],
warp: ['WARP_DB_PATH'],
antigravity: ['CODEBURN_CACHE_DIR'],
'kilo-code': ['XDG_DATA_HOME'],
kimi: ['KIMI_SHARE_DIR', 'KIMI_MODEL_NAME'],
kiro: ['KIRO_HOME'],
'mistral-vibe': ['VIBE_HOME'],
mux: ['MUX_ROOT', 'CODEBURN_MUX_DIR'],
qwen: ['QWEN_DATA_DIR'],
'ibm-bob': ['XDG_CONFIG_HOME'],
'ibm-bob': ['XDG_CONFIG_HOME', 'APPDATA'],
quickdesk: ['QUICKWORK_HOME'],
kimicode: ['KIMI_CODE_HOME'],
zerostack: ['ZS_DATA_DIR', 'XDG_DATA_HOME'],
// The gateway credential is a deliberate user override and MUST move the
// fingerprint: a read-only refresh (the refresh-lock fallback) serves the
// cached report straight from the section (parser.ts:1442 seeds servedSources
// before the network re-fetch at parser.ts:1455, which only runs when
// !readOnly), so an undeclared credential would keep serving the previous
// account's usage after a swap — the exact #920 defect.
'vercel-gateway': ['AI_GATEWAY_API_KEY', 'VERCEL_OIDC_TOKEN'],
}

// Names of providers whose cache entries are never evicted when source files
Expand Down
113 changes: 113 additions & 0 deletions packages/cli/tests/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { tmpdir } from 'os'

import { collectDoctorReport, renderDoctorTable, renderDoctorJson } from '../src/doctor.js'
import { createCodexProvider } from '../src/providers/codex.js'
import { createOpenCodeProvider } from '../src/providers/opencode.js'
import { emptyCache, type SessionCache } from '../src/session-cache.js'
import type { Provider, ProbeRoot, SessionSource } from '../src/providers/types.js'

Expand Down Expand Up @@ -141,6 +142,118 @@ describe('collectDoctorReport - env override', () => {
else process.env['CODEX_HOME'] = prev
}
})

it('names a deliberate XDG_DATA_HOME override pointing at a missing dir, blaming the override not the install (opencode)', async () => {
const prev = process.env['XDG_DATA_HOME']
const bogus = join(tmpDir, 'xdg-missing')
process.env['XDG_DATA_HOME'] = bogus
try {
// Construct after setting env so the provider resolves XDG_DATA_HOME
// (src/providers/opencode.ts:42 reads it to resolve the data dir).
const provider = createOpenCodeProvider()
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
const r = only(report, 'opencode')

expect(r.envOverrides).toContainEqual({ name: 'XDG_DATA_HOME', value: bogus })
expect(r.status).toBe('empty')
// Regression (Ruling 3 of lane 04): with XDG_DATA_HOME treated as an
// ambient OS var, doctor skipped it and the verdict blamed the install
// ("tool likely not installed") instead of the override the user set.
expect(r.verdict).toContain('override XDG_DATA_HOME set')
expect(r.verdict).toContain('does not exist')
} finally {
if (prev === undefined) delete process.env['XDG_DATA_HOME']
else process.env['XDG_DATA_HOME'] = prev
}
})

// Windows sets APPDATA and LOCALAPPDATA for every process, so neither
// carries user intent: both are fingerprinted (a change moves the discovery
// root) but must never be named as a deliberate override (Ruling 3 of lane
// 04). Table-driven over both so removing either from AMBIENT_ENV_VARS
// fails a test instead of leaking it into the overrides list.
for (const varName of ['APPDATA', 'LOCALAPPDATA']) {
it(`does not name ${varName} as an override for a provider that declares it`, async () => {
const prev = process.env[varName]
process.env[varName] = join(tmpDir, varName.toLowerCase())
try {
const provider = fakeProvider({ name: 'claude', displayName: 'Claude' })
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
const r = only(report, 'claude')

expect(r.envOverrides.some(o => o.name === varName)).toBe(false)
} finally {
if (prev === undefined) delete process.env[varName]
else process.env[varName] = prev
}
})
}

// Every credential in SECRET_ENV_VARS must be redacted at collect time so
// neither the text render nor the JSON report can leak it (Ruling 2 of lane
// 04). Table-driven over both, so a credential added to the set without a
// redaction test fails here instead of leaking into a bug report.
for (const varName of ['AI_GATEWAY_API_KEY', 'VERCEL_OIDC_TOKEN']) {
it(`redacts credential values (${varName}) from overrides, the table render, and the JSON report`, async () => {
const secret = `sk-live-${varName}-value-12345`
const prev = process.env[varName]
const sibling = varName === 'AI_GATEWAY_API_KEY' ? 'VERCEL_OIDC_TOKEN' : 'AI_GATEWAY_API_KEY'
const prevSibling = process.env[sibling]
process.env[varName] = secret
// Isolate the case under test: a stray ambient sibling must not change
// what this case observes.
delete process.env[sibling]
try {
const provider = fakeProvider({ name: 'vercel-gateway', displayName: 'Vercel AI Gateway', network: true })
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
const r = only(report, 'vercel-gateway')

// The "is this credential set?" diagnostic is useful; the value is a
// live secret and must never leave doctor (Ruling 2 of lane 04).
expect(r.envOverrides).toContainEqual({ name: varName, value: '<set>' })
expect(r.envOverrides.some(o => o.value.includes(secret))).toBe(false)
const table = renderDoctorTable(report, { color: false })
expect(table).toContain(`${varName}=<set>`)
expect(table).not.toContain(secret)
expect(renderDoctorJson(report)).not.toContain(secret)
} finally {
if (prev === undefined) delete process.env[varName]
else process.env[varName] = prev
if (prevSibling === undefined) delete process.env[sibling]
else process.env[sibling] = prevSibling
}
})
}

// CODEBURN_CURSOR_MAX_BUBBLES caps how many bubbles Cursor parses
// (src/providers/cursor.ts:530) and KIMI_MODEL_NAME renames the model
// attributed to Kimi sessions (src/providers/kimi.ts:125): both are
// fingerprinted but cannot explain why nothing was discovered, so the
// verdict must not name them — while Details still lists them, because they
// ARE overrides in force. Each is asserted through the provider that
// declares it.
for (const [varName, providerName, displayName, value] of [
['CODEBURN_CURSOR_MAX_BUBBLES', 'cursor', 'Cursor', '5000'],
['KIMI_MODEL_NAME', 'kimi', 'Kimi', 'kimi-latest-920'],
] as const) {
it(`does not blame ${varName} for an empty ${displayName} (not a discovery path)`, async () => {
const prev = process.env[varName]
process.env[varName] = value
try {
const provider = fakeProvider({ name: providerName, displayName })
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
const r = only(report, providerName)

expect(r.envOverrides).toContainEqual({ name: varName, value })
expect(r.verdict).not.toContain(varName)
const table = renderDoctorTable(report, { color: false })
expect(table).toContain(`${varName}=${value}`)
} finally {
if (prev === undefined) delete process.env[varName]
else process.env[varName] = prev
}
})
}
})

// ── Synthetic edge cases ───────────────────────────────────────────────────
Expand Down
Loading
Loading