Refuse to open a registry this machine does not own (#87) - #88
Conversation
The desktop app is the one surface with no protocol client: served mode (#85, D-017) moved the database boundary for agent sessions, but the Electron main process still builds `new Registry(...)` over whatever file sits at the canonical path. On a machine that is not the registry owner that is a *different* database, and it does not error — it silently shows and mutates stale data, and when the file is absent `initDb` creates an empty registry that presents as total data loss. This is option (b) of #87 — the refusal, not the remote mode. `packages/app/src/main/db-owner.ts` decides, from evidence on disk, whether this machine may open the local registry. It runs in `app.whenReady()` before `registerIpcHandlers` (which constructs the `Registry`), before the tray and before the shortcuts, so a refusal has no IPC surface at all and nothing reads or writes the database file. The refusal is a window, not a log line: scriptless static HTML, no preload, sandboxed, naming the owner, the served URL, the file it did not open, and what to do about it. Closing it quits, whatever menu-bar persistence says. Two signals, in precedence order: - `<data-dir>/registry-remote.json` (or `SETLIST_REGISTRY_REMOTE_URL`), an operator's declaration that this machine's registry is served elsewhere. This is the only signal that can catch an *absent* local file, which is the laptop's state after the cutover runbook. - `<data-dir>/db-owner.json` naming a different machine — a copied or cloned data directory, caught with no operator action. The app writes this marker for itself on first launch. The asymmetries are the design: - Refusal requires positive evidence. A missing marker, an unparseable marker, and an undeterminable machine identity all allow: a guard whose false positive is "the app will not start" must not fire on ambiguity, because the failure it prevents is silent divergence, not startup. - A blank `SETLIST_REGISTRY_REMOTE_URL` means unset (env vars get exported empty by accident), but a `registry-remote.json` that exists without a usable URL is an error rather than a fallback to the local file — that file exists only because somebody wrote it. Same rule as served mode's empty token file. - Identity is hardware-derived (macOS IOPlatformUUID, Linux machine-id), never the hostname: hostnames churn, and a hostname-only mismatch would refuse to start on the owner machine. - `SETLIST_APP_DB_OWNER_OVERRIDE=1` is the documented last resort. Tests: 28 unit cases over the predicate (both refusal signals, every allow-on-ambiguity path, the pointer shapes an operator would write, the blank-vs-invalid asymmetry, the override, HTML escaping, and a temp-dir claim/refuse walk asserting no database file appears), plus an Electron e2e launch against a data dir claimed by another machine asserting the refusal window, the absence of registry.db/-wal/-shm, and that no `window.setlist` bridge exists. Docs: served-mode §3.8.1 (the guard, the files, the env vars, the honest limits), runbook Phase 7 now writes the pointer as its first step, a new State-ownership invariant (ENFORCED), and CLAUDE.md. Still open in #87: the app resolving its ~60 IPC channels through an MCP client against the served registry. The CLI and MCP server on a non-owner machine remain unguarded, and a machine with neither file gets no protection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Twbdbg1U4BJo5tz7vFegsx
Mechanical: `npm run build -w packages/app` output for the owner guard. The tracked bundle also picks up drift from earlier merges that did not rebuild it (#63 — nothing asserts `out/` matches source). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Twbdbg1U4BJo5tz7vFegsx
🤖 Codex review — REQUEST_CHANGESLocal Codex review of this PR's changes against The ownership guard has a startup-order regression: refusal mode still imports the full IPC/core stack before it can evaluate ownership, so it can fail before showing the intended refusal window. Findings (1)
|
Codex flagged that `index.ts` reached `resolveAppDbPath` through `./ipc.js`,
which imports `@setlist/core` (and so `better-sqlite3`) at module load —
before the guard could run. The stated crash does not reproduce
(better-sqlite3 loads its addon lazily inside the `Database` constructor,
`lib/database.js:48`, so refusal mode survived even with the native binary
removed entirely — verified), but relying on a dependency's internal
laziness to keep the "do not open this database" window alive is the wrong
guarantee. Make it structural:
- `db-path.ts` resolves the path with no `@setlist/core` /
`better-sqlite3` import. It re-derives core's canonical location as a
*checked* duplicate: `db-path.test.ts` asserts it equals `getDbPath()`,
so the mirror cannot drift silently.
- `./ipc.js` is now loaded with `await import()` only after ownership is
allowed, and lands in its own bundle chunk (`out/main/ipc-*.js`).
Verified: the refusal e2e passes with the native binding absent; the allow
path still claims the data dir and loads the renderer normally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Twbdbg1U4BJo5tz7vFegsx
Response to the Codex finding (P2,
|
🤖 Codex review — APPROVELocal Codex review of this PR's changes against No blocking correctness issues found. I reviewed the HEAD diff against origin/main, including the owner-guard decision logic, Electron startup integration, IPC loading boundary, tests, docs, and generated app output. Typecheck could not be run because No issues found. |
Ships option (b) of #87 — the cheap refusal, not the remote mode.
The failure this removes
packages/applinks better-sqlite3 and opensregistry.dbdirectly. Served mode (#85, D-017) moved the database boundary for agent sessions but gave the app no client, so on a machine that is not the registry owner the app opens whatever file sits at the canonical path — a different database. It does not error: it silently shows and mutates stale data, and when the file is absentinitDbcreates a fresh empty registry, which presents as total data loss (served-mode §3.8).What it does
packages/app/src/main/db-owner.tsdecides from evidence on disk whether this machine may open the local registry. It runs inapp.whenReady()beforeregisterIpcHandlers(the site that constructs theRegistry), before the tray, and before the shortcuts — so a refusal has no IPC surface at all and nothing reads or writes the database file. The refusal is a window, not a log line: scriptless static HTML, no preload, sandboxed, naming the owner, the served URL, the file it did not open, and what to do. Closing it quits, whatever menu-bar persistence says.Two signals, in precedence order:
<data-dir>/registry-remote.json(orSETLIST_REGISTRY_REMOTE_URL)<data-dir>/db-owner.jsonnaming a different machineThe asymmetries (they are the design, not decoration)
SETLIST_REGISTRY_REMOTE_URLmeans unset (env vars get exported empty by accident), but aregistry-remote.jsonthat exists without a usable URL is an error, not a fallback to the local file — that file exists only because somebody wrote it. Same rule as served mode's empty token file.IOPlatformUUID, Linuxmachine-id), never the hostname: hostnames churn, and a hostname-only mismatch would refuse to start on the owner machine.SETLIST_APP_DB_OWNER_OVERRIDE=1is the documented last resort, offered as the last step of every refusal.Evidence
packages/app/src/main/db-owner.test.ts— 28 cases over the predicate: both refusal signals, every allow-on-ambiguity path, the pointer shapes an operator would actually write, blank-vs-invalid, the override, HTML escaping of values read off disk, and a real temp-dir claim → re-read-as-owner → refuse-a-foreign-dir walk asserting no database file ever appears.packages/app/e2e/app.spec.ts— a real Electron launch against a data dir claimed by another machine: the refusal window renders,registry.db/-wal/-shmdo not exist, andwindow.setlistis absent. Verified locally (npm run test:e2e -- --grep "refuses to open a registry"→ passed); also verified by hand that the allow path still launches normally, claims the data dir, and loads the renderer.npm test1197 passed ·npm run typecheckclean ·npm run build -w packages/app+npm run verify:mcp-abiOK.Docs / agent model
chmod 444neutralization stays, because the CLI is not covered.Known limits, stated rather than papered over
PATH).out/bundle rebuild (app: committed packages/app/out/ bundle drifts from source per-PR (silent) #63 — nothing asserts the tracked bundle matches source; it also picks up drift from earlier merges).Addresses #87; the remote-capable app (option (a)) stays open there.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Twbdbg1U4BJo5tz7vFegsx