mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-19 18:23:41 +00:00
fix: never let a Full Disk Access blocked Warp database wedge the run (#1481)
macOS gates ~/Library/Group Containers behind Full Disk Access, and an open() there never returns when the process has no consent and cannot prompt, so codeburn status and the resident serve child both hung with no output. Any database under that prefix is now opened first in a killable child with a three second timeout; on a timeout or refusal it is skipped with one warning naming Full Disk Access, warp is reported as failed discovery rather than empty, serve skips fs.watch on the same path, and the result is cached for a minute so access granted later is picked up. A probe spawn failure is never treated as blocked.
This commit is contained in:
parent
86b3134694
commit
6f91914214
6 changed files with 136 additions and 4 deletions
|
|
@ -28,6 +28,7 @@
|
|||
- **The Capacity Dock card and the popover quota rows no longer print a pace verdict under each window.** The "On pace" / "Lasts until reset" caption from #1314 added a third line to every window cell and made the card read as clutter; the cells are back to percent, label and countdown, up to three windows share one row again with a fourth wrapping to a second, and the pace math still feeds the tooltip and the agent-tab card.
|
||||
|
||||
### Fixed
|
||||
- **A Warp database macOS will not let CodeBurn open no longer freezes the whole run.** Warp keeps its SQLite file inside a Group Container, which macOS puts behind Full Disk Access. Without that permission, and with no way to show the prompt, the open never returns: `codeburn status` printed nothing at all, and the resident `serve` process the desktop and menubar talk to hung with it, so every surface went blank with no error. Any database under `~/Library/Group Containers` is now opened first in a short-lived child process that can be killed; if that child does not come back within three seconds, or is refused, the database is skipped, one line on stderr says so and names Full Disk Access as the fix, and Warp is reported as a provider whose discovery failed rather than one with no sessions, so the rest of the run keeps its own numbers. The check is remembered for a minute, so the resident process does not repeat it per request and granting access is picked up on the next refresh without a restart.
|
||||
- **A Devin step run on `gpt-5-3-codex-xhigh` is priced at the Codex row it names, not the base `gpt-5` one.** Devin writes the model's minor version with a dash, and that spelling has no pricing row, so lookup fell through to `gpt-5` and undercharged every Codex-backed step by $0.50 per million input tokens. The id is now normalised to `gpt-5.3-codex-xhigh` for pricing, the same rewrite the display name already used.
|
||||
- **Model tables add up to the headline above them.** `codeburn report --format json` took its totals from the durable daily cache but built its model rows from surviving sessions, so on a long period the rows summed to less than the headline; they now come from the same days the headline and the daily rows do. Days recorded before CodeBurn tracked usage per model kept their cost and calls but had nothing to attribute them to, and that money simply went missing from every model table. It now shows as a row named "Unknown (carried)" — the same figure, named instead of silently dropped.
|
||||
- **OpenCode 2.x sessions show up again.** OpenCode 2.x (mainline since 2.0.3) writes sessions to a new `session_v2` table and messages to `session_message`, tagged by type and ordered by sequence, while the old `session`/`message`/`part` tables freeze at upgrade — so every session created after the upgrade was invisible. CodeBurn now branches per database on what actually exists: when the 2.x tables are there they are read and the frozen legacy tables are ignored, otherwise the legacy path runs exactly as before. Compaction requests keep their own cost and tokens counted, as they were on 1.x. (#1293)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { rooCode } from './roo-code.js'
|
|||
import { zerostack } from './zerostack.js'
|
||||
import { grok } from './grok.js'
|
||||
import { grokbot } from './grokbot.js'
|
||||
import { isBlockedDatabaseError } from '../sqlite.js'
|
||||
import type { Provider, SessionSource } from './types.js'
|
||||
|
||||
let antigravityProvider: Provider | null = null
|
||||
|
|
@ -266,7 +267,9 @@ async function discoverOne(provider: Provider): Promise<{ sources: SessionSource
|
|||
try {
|
||||
return { sources: await provider.discoverSessions(), failed: false }
|
||||
} catch (err) {
|
||||
if (!warnedDiscoveryFailures.has(provider.name)) {
|
||||
// An error that already explained itself on stderr does not need a second,
|
||||
// vaguer line.
|
||||
if (!warnedDiscoveryFailures.has(provider.name) && !isBlockedDatabaseError(err)) {
|
||||
warnedDiscoveryFailures.add(provider.name)
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
process.stderr.write(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { homedir } from 'os'
|
|||
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import { blobToText, getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js'
|
||||
import { blobToText, getSqliteLoadError, isBlockedDatabaseError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js'
|
||||
import { estimateTokensFromChars } from '../token-estimate.js'
|
||||
import type { ProbeRoot, ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
|
||||
import { safeNumber } from '../parser.js'
|
||||
|
|
@ -426,7 +426,10 @@ async function discoverFromDb(dbPath: string): Promise<SessionSource[]> {
|
|||
let db: SqliteDatabase
|
||||
try {
|
||||
db = openDatabase(dbPath)
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// A blocked database means "unknown", not "no sessions": let it reach the
|
||||
// registry so the run is marked degraded instead of silently empty.
|
||||
if (isBlockedDatabaseError(err)) throw err
|
||||
return []
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { getConfigFilePath } from './config.js'
|
|||
import type { ParseReuseValidation } from './parser.js'
|
||||
import { SERVE_HYDRATION_ENV } from './usage-aggregator.js'
|
||||
import { suppressProjectFilterWarnings } from './project-filter-warnings.js'
|
||||
import { isPathBlocked } from './sqlite.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// codeburn serve --stdio: a resident query server for the desktop app.
|
||||
|
|
@ -508,6 +509,9 @@ async function startRootWatchers(): Promise<RootWatcherState | null> {
|
|||
}
|
||||
}
|
||||
for (const root of roots) {
|
||||
// A root we refuse to open contributes no sessions, so nothing about it
|
||||
// can go stale; watching it would hang this loop the way opening it does.
|
||||
if (isPathBlocked(root)) continue
|
||||
let info: Awaited<ReturnType<typeof stat>>
|
||||
try {
|
||||
info = await stat(root)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { createRequire } from 'node:module'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, utimesSync } from 'node:fs'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { join, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { getCodeburnCacheDir } from './cache-dir.js'
|
||||
|
|
@ -407,10 +409,73 @@ function openReadonlyCache(path: string, originalError: unknown): DatabaseSyncIn
|
|||
return new Driver(cachedPath, { readOnly: true })
|
||||
}
|
||||
|
||||
/// macOS keeps `~/Library/Group Containers` behind Full Disk Access. When the
|
||||
/// process running codeburn has no consent and cannot show the prompt, open()
|
||||
/// on a database in there never returns: the whole run wedges before printing
|
||||
/// anything, and so does the resident `serve` child. fs.accessSync/openSync
|
||||
/// block the same way, so the only safe probe is a child we can kill.
|
||||
const GROUP_CONTAINERS_PREFIX = join(homedir(), 'Library', 'Group Containers') + sep
|
||||
const TCC_PROBE = "try{require('node:fs').closeSync(require('node:fs').openSync(process.argv[1],'r'))}catch(e){process.exit(e.code==='ENOENT'?2:1)}"
|
||||
const TCC_PROBE_TIMEOUT_MS = 3000
|
||||
/// Short enough that granting access recovers on the next `serve` refresh,
|
||||
/// long enough that a resident process does not fork per request.
|
||||
const TCC_PROBE_CACHE_MS = 60_000
|
||||
|
||||
const tccProbeCache = new Map<string, { blocked: boolean; at: number }>()
|
||||
let warnedTccBlocked = false
|
||||
|
||||
export function isBlockedDatabaseError(err: unknown): boolean {
|
||||
return typeof err === 'object' && err !== null && 'codeburnBlocked' in err
|
||||
}
|
||||
|
||||
/// `script` and `execPath` are only ever overridden by tests, which cannot make
|
||||
/// a real open() hang on demand nor break the spawn itself.
|
||||
export function probeDatabaseBlocked(path: string, script = TCC_PROBE, execPath = process.execPath): boolean {
|
||||
const now = Date.now()
|
||||
const key = `${script}\u0000${path}`
|
||||
const cached = tccProbeCache.get(key)
|
||||
if (cached && now - cached.at < TCC_PROBE_CACHE_MS) return cached.blocked
|
||||
|
||||
const probe = spawnSync(execPath, ['-e', script, path], {
|
||||
timeout: TCC_PROBE_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
stdio: 'ignore',
|
||||
// process.execPath is Electron's binary in the desktop app; without this it
|
||||
// would launch a second app window instead of running the script.
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
})
|
||||
// 0 readable, 2 missing (let the real open report that); the timeout kill and
|
||||
// any other exit mean we must not touch this file. A probe we could not run
|
||||
// at all says nothing about the file, so it must not block it.
|
||||
const blocked = probe.signal === 'SIGKILL' || (!probe.error && probe.status !== 0 && probe.status !== 2)
|
||||
tccProbeCache.set(key, { blocked, at: now })
|
||||
return blocked
|
||||
}
|
||||
|
||||
/// True for a Group Container path this process must not open: kqueue-backed
|
||||
/// fs.watch hangs on it exactly like DatabaseSync does.
|
||||
export function isPathBlocked(path: string, script = TCC_PROBE): boolean {
|
||||
return path.startsWith(GROUP_CONTAINERS_PREFIX) && probeDatabaseBlocked(path, script)
|
||||
}
|
||||
|
||||
export function assertDatabaseReadable(path: string, script = TCC_PROBE): void {
|
||||
if (!isPathBlocked(path, script)) return
|
||||
|
||||
if (!warnedTccBlocked) {
|
||||
warnedTccBlocked = true
|
||||
process.stderr.write(
|
||||
"codeburn: skipped warp: macOS blocked access to Warp's database; grant Full Disk Access to the app " +
|
||||
'running codeburn (System Settings > Privacy & Security) and retry\n',
|
||||
)
|
||||
}
|
||||
throw Object.assign(new Error(`macOS blocked access to ${path}`), { codeburnBlocked: true })
|
||||
}
|
||||
|
||||
export function openDatabase(path: string): SqliteDatabase {
|
||||
if (!loadDriver() || DatabaseSync === null) {
|
||||
throw new Error(getSqliteLoadError())
|
||||
}
|
||||
assertDatabaseReadable(path)
|
||||
|
||||
let db: DatabaseSyncInstance
|
||||
let fallbackUsed = false
|
||||
|
|
|
|||
56
tests/sqlite-tcc-preflight.test.ts
Normal file
56
tests/sqlite-tcc-preflight.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { assertDatabaseReadable, isBlockedDatabaseError, probeDatabaseBlocked } from '../src/sqlite.js'
|
||||
|
||||
// A real open() on a Full-Disk-Access-protected path never returns, and no test
|
||||
// can reproduce that; a child that just sleeps wedges the preflight the same way.
|
||||
const SLEEPS_FOREVER = 'setTimeout(() => {}, 1e9)'
|
||||
const groupContainerPath = (name: string) =>
|
||||
join(homedir(), 'Library', 'Group Containers', '2BBY89MBSN.dev.warp.test', name)
|
||||
|
||||
describe('Group Container preflight', () => {
|
||||
it('gives up on a child that never exits', () => {
|
||||
const started = Date.now()
|
||||
expect(probeDatabaseBlocked(groupContainerPath('hang.sqlite'), SLEEPS_FOREVER)).toBe(true)
|
||||
expect(Date.now() - started).toBeLessThan(10_000)
|
||||
})
|
||||
|
||||
it('passes a readable file', () => {
|
||||
const file = join(mkdtempSync(join(tmpdir(), 'codeburn-tcc-')), 'warp.sqlite')
|
||||
writeFileSync(file, 'x')
|
||||
expect(probeDatabaseBlocked(file)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not blame the file when the probe itself cannot run', () => {
|
||||
const missingNode = join(tmpdir(), 'codeburn-no-such-node')
|
||||
expect(probeDatabaseBlocked(groupContainerPath('spawn-fail.sqlite'), SLEEPS_FOREVER, missingNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('warns once per run and leaves the error marked as blocked', () => {
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
try {
|
||||
let thrown: unknown
|
||||
for (const name of ['first.sqlite', 'second.sqlite']) {
|
||||
try {
|
||||
assertDatabaseReadable(groupContainerPath(name), SLEEPS_FOREVER)
|
||||
throw new Error('expected the preflight to throw')
|
||||
} catch (err) {
|
||||
thrown = err
|
||||
}
|
||||
}
|
||||
expect(isBlockedDatabaseError(thrown)).toBe(true)
|
||||
const warnings = stderr.mock.calls.filter(([msg]) => String(msg).includes('Full Disk Access'))
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]![0]).toBe(
|
||||
"codeburn: skipped warp: macOS blocked access to Warp's database; grant Full Disk Access to the app " +
|
||||
'running codeburn (System Settings > Privacy & Security) and retry\n',
|
||||
)
|
||||
} finally {
|
||||
stderr.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue