From a81a9c31a15e66818afb4c7eacc5935db14bf19e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 16:36:00 +0000 Subject: [PATCH 1/3] Sync the pit's settings on its own, every five minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aliases you built on the desktop should be on the laptop by the time you sit down at it, and `/save` only got typed by people who remembered it existed. An open pit now runs `/load` then `/save` every five minutes. PRD 0010 ruled background sync out, and it was right about the daemon it had in mind: "a daemon that pushes silently is a daemon that overwrites silently." This one is allowed because it is never permitted to force. Both refusals the feature already had do the work — `/load` declines when a settings file changed locally since the last sync, `/save` declines on the 409 when another machine saved first — so the worst an unattended tick can do is stop and leave the decision with the person at the prompt. The non-goal is narrowed rather than dropped, and R10 records what replaced it. Loading before saving is the design, not an ordering accident. It puts this machine at the account's revision before it pushes, so the ordinary two-machine case never becomes a conflict anyone has to resolve; and when `/load` declines over local edits, the `/save` behind it pushes exactly those edits, which is what the manual conflict message tells you to do anyway. Quiet on purpose. Nothing is printed when nothing changed, when there is no token, or when the network is down — a line every five minutes saying "still fine" trains you to stop reading the pit. Four things speak: settings that arrived from another machine, a revision this machine pushed, a conflict, and a credential the app rejected. Answers are read via `--json` rather than by matching on English. Three lifecycle details the pit forced: the timer is stopped in the teardown because `tui()` is re-entered after an engine session and would otherwise collect one timer per entry; it holds no reference to `rl`, which the loop closes and rebuilds around a dozen commands; and it is unref'd, so a piped `/quit` exits now rather than in five minutes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XGe9mWC6FvUjT6p1HCVjba --- README.md | 23 +++ prd/0010-cloud-settings-sync.md | 19 ++- src/autosync.mjs | 175 ++++++++++++++++++++++ src/tui.mjs | 10 ++ test/autosync.test.mjs | 255 ++++++++++++++++++++++++++++++++ 5 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 src/autosync.mjs create mode 100644 test/autosync.test.mjs diff --git a/README.md b/README.md index 6a7566be..0f14327d 100644 --- a/README.md +++ b/README.md @@ -1199,6 +1199,29 @@ Nothing is overwritten quietly: Both verbs take `--json`, so a provisioning script can act on the result. +### It also syncs on its own + +An open pit runs `/load` then `/save` every five minutes, so the aliases you +made on the desktop are on the laptop by the time you sit down at it. It is on +by default and there is nothing to configure. + +What makes that safe is that it is never allowed to force. Both refusals above +still apply to it: a tick that finds a settings file you edited locally leaves +it alone and pushes it instead, and a tick that finds another machine got there +first stops and tells you, rather than picking a winner. The order matters — +loading first is what keeps the ordinary two-machine case from ever becoming a +conflict you have to resolve by hand. + +It is quiet on purpose. Nothing is printed when nothing changed, when you are +not logged in, or when the network is down. Four things get a line: settings +arriving from another machine (your aliases just changed under you), a revision +this machine pushed, a conflict, and a credential the app rejected. + +```sh +MOSHCODE_NO_AUTOSYNC=1 moshcode # turn it off for this pit +MOSHCODE_AUTOSYNC_MS=900000 moshcode # every fifteen minutes instead +``` + ## Browser terminal (`moshcode console`) A real terminal in the browser — arrow keys, history, full-screen TUIs — because diff --git a/prd/0010-cloud-settings-sync.md b/prd/0010-cloud-settings-sync.md index c1e3d509..ae3a4e65 100644 --- a/prd/0010-cloud-settings-sync.md +++ b/prd/0010-cloud-settings-sync.md @@ -41,8 +41,13 @@ configuration is already there and already paired with every machine. ## Non-Goals -- Continuous or background sync. Settings are edited by a person at a moment they - can name; a daemon that pushes silently is a daemon that overwrites silently. +- Background sync that can *overwrite*. This line used to rule out background + sync altogether — "a daemon that pushes silently is a daemon that overwrites + silently" — and the reasoning was right about the daemon it imagined. R10 + narrows it rather than dropping it: the pit does sync on its own, and is + allowed to because it is never permitted to force. Every refusal in R3 and R4 + is what makes an unattended tick safe, and a background sync that could pass + `--force` would be exactly the thing this line was written to prevent. - Syncing engine configuration (`~/.claude.json`, `~/.codex`, MCP registrations). Those files carry provider API keys and are owned by other tools' schemas. - Syncing machine state: live herd sessions, the package cache, shell history. @@ -83,6 +88,16 @@ configuration is already there and already paired with every machine. - R9 [P2] Not logged in, session expired, nothing saved yet, conflict: each is a sentence naming the command that resolves it (`/login`, `/save`, `/load`, `--force`). +- R10 [P1] The pit syncs on its own every five minutes: `/load` then `/save`, in + that order, never with `--force`. Loading first means the ordinary + two-machine case settles itself; when `/load` declines because of unsaved + local edits, the `/save` behind it carries exactly those edits up, which is + the resolution R4 already recommends. It is silent when logged out, silent + when nothing changed, and silent about network failure; it speaks only for + settings that arrived from another machine, a revision it pushed, and the two + states that need a person — a conflict and a rejected credential. On by + default. `MOSHCODE_NO_AUTOSYNC` turns it off, `MOSHCODE_AUTOSYNC_MS` retimes + it. ## UX Notes diff --git a/src/autosync.mjs b/src/autosync.mjs new file mode 100644 index 00000000..31021d10 --- /dev/null +++ b/src/autosync.mjs @@ -0,0 +1,175 @@ +// Automatic settings sync — the unattended `/load` then `/save`. +// +// PRD 0010 ruled background sync out, and the reason it gave was the right +// reason for the mechanism it had in mind: "a daemon that pushes silently is a +// daemon that overwrites silently." What makes this one allowed is that it is +// not permitted to overwrite anything. It never passes `--force`, and both +// verbs already refuse rather than guess — `/load` stops when a settings file +// changed locally since the last sync, `/save` stops on the 409 when another +// machine saved first. So the worst an unattended tick can do is decline and +// leave the decision exactly where it was: with the person at the prompt. +// +// The order is `/load` then `/save`, and that order is the whole design: +// +// - `/load` first means this machine is at the account's revision before it +// pushes, so the ordinary two-machine case settles itself and nobody is +// ever shown a conflict they would only have resolved by loading anyway. +// - When `/load` declines because there are unsaved local edits, the `/save` +// that follows pushes exactly those edits — which is the resolution the +// manual conflict message already recommends ("`/save` to keep them"). +// +// Quiet is a feature. A tick that changed nothing prints nothing, because a +// line every five minutes saying "still fine" trains you to stop reading the +// pit. Three things do print: settings that arrived from another machine (your +// aliases just changed under you and you are owed that sentence), a revision +// this machine pushed, and the two states that need a human — a conflict, and +// credentials the app rejected. Network failures stay silent; a laptop on a +// train would otherwise narrate every tunnel. +import os from "node:os"; +import { loadCreds } from "./auth.mjs"; +import { loadCommand, saveCommand } from "./settings-sync.mjs"; + +/** Five minutes. Long enough that a tick is never in the way of typing. */ +export const DEFAULT_INTERVAL_MS = 5 * 60 * 1000; + +/** + * A floor, not a suggestion. `MOSHCODE_AUTOSYNC_MS=1` would turn the account + * into a write loop, so anything under this is treated as the minimum rather + * than refused — an env var is not the place to learn you typed milliseconds + * where you meant minutes. + */ +export const MIN_INTERVAL_MS = 30 * 1000; + +/** + * Off switch, in the shape the rest of the codebase already uses for one: + * presence disables, exactly like MOSHCODE_NO_MIRROR and MOSHCODE_NO_ADS. + */ +export function autoSyncEnabled(env = process.env) { + return !env.MOSHCODE_NO_AUTOSYNC; +} + +/** `Number(x) || default`, the MOSHCODE_AD_COLS idiom, with a floor. */ +export function autoSyncInterval(env = process.env) { + const raw = Number(env.MOSHCODE_AUTOSYNC_MS); + if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_INTERVAL_MS; + return Math.max(MIN_INTERVAL_MS, raw); +} + +/** + * Run one verb and read its answer as data rather than as prose. + * + * Both commands take `--json` and emit a single object through their `write` + * sink, which is the only reason this can be quiet: it can tell "loaded four + * files" from "already at revision 9" without matching on English. + */ +async function runJson(command, argv, deps) { + const chunks = []; + const code = await command([...argv, "--json"], { + ...deps, + write: (line) => chunks.push(String(line)), + }); + let body = null; + try { body = JSON.parse(chunks.join("\n")); } catch { /* not our business */ } + return { code, body, status: body?.status ?? null }; +} + +/** + * One tick: load, then save. + * + * Returns what happened, so the caller decides what is worth a line and the + * tests can assert on the sequence without reading output. + */ +export async function syncOnce({ + load = loadCommand, + save = saveCommand, + creds = loadCreds(), + write = () => {}, + ...deps +} = {}) { + // Logged out is not an error and must never print. A pit that has never seen + // `/login` would otherwise nag about an account its owner has not asked for, + // every five minutes, forever. + if (!creds?.token) return { skipped: "not_logged_in" }; + + const loaded = await runJson(load, [], { ...deps, creds }); + + // `local_changes` is the expected, healthy half of this: you edited an alias + // and have not saved it. `/load` correctly declined to replace it, and the + // `/save` below is what carries it up. Anything else that failed is a reason + // to stop rather than push on top of a machine we could not read. + const loadBlocked = loaded.status === "expired"; + if (loadBlocked) { + write("the app rejected this machine's credentials — run `/login` again"); + return { load: loaded.status, save: null }; + } + + if (loaded.status === "loaded") { + const count = Array.isArray(loaded.body?.files) ? loaded.body.files.length : 0; + const from = loaded.body?.from; + write(`settings synced${from ? ` from ${from}` : ""} — ${count} file${count === 1 ? "" : "s"} changed (revision ${loaded.body?.revision ?? "?"})`); + } + + const saved = await runJson(save, [], { ...deps, creds }); + + if (saved.status === "saved") { + write(`settings saved — revision ${saved.body?.revision ?? "?"}`); + } else if (saved.status === "conflict") { + // The one case an unattended tick cannot resolve: this machine loaded, and + // the account moved again between the load and the save. Say so once and + // stop; `--force` is a decision, not a retry. + write(`another machine saved first — \`/load\` to take theirs, or \`/save --force\` to keep this machine's`); + } else if (saved.status === "expired") { + write("the app rejected this machine's credentials — run `/login` again"); + } + + return { load: loaded.status, save: saved.status }; +} + +/** + * Start the timer. Returns the function that stops it. + * + * The caller must call that on the way out: `tui()` is re-entered after an + * engine session (bin/moshcode.mjs `backToPit`), so a timer left running would + * be joined by another on the next entry, and by a third after that. + */ +export function startAutoSync({ + intervalMs = autoSyncInterval(), + enabled = autoSyncEnabled(), + write = (line) => console.log(` ${line}`), + timers = { setInterval, clearInterval }, + ...deps +} = {}) { + if (!enabled) return () => {}; + + // A tick that is still running when the next one fires would race two writes + // to the same files, so ticks are single-flight rather than queued: a sync + // this machine skipped is one it does five minutes later, unchanged. + let running = false; + let stopped = false; + + const tick = async () => { + if (running || stopped) return; + running = true; + try { await syncOnce({ write, ...deps }); } + catch { /* a background sync never takes the pit down with it */ } + finally { running = false; } + }; + + // Deliberately no tick at startup. The pit is most likely to be typed into in + // the second after it opens, and that is the worst moment to rewrite the + // aliases under it — the first sync can wait five minutes. + const handle = timers.setInterval(tick, intervalMs); + + // Never hold the process open for the sake of a sync. `pty.mjs` sets the + // precedent: a piped `moshcode` that has run out of stdin should exit now, + // not at the end of the interval. + handle?.unref?.(); + + return () => { + stopped = true; + try { timers.clearInterval(handle); } catch { /* already gone */ } + }; +} + +/** Exported for the tests; the pit has no reason to care. */ +export const _internals = { runJson, hostname: os.hostname }; diff --git a/src/tui.mjs b/src/tui.mjs index 16b3877b..55618983 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -16,6 +16,7 @@ import { runUpgrade } from "./upgrade.mjs"; import { locate, tilde } from "./pwd.mjs"; import { createPrd, listPrds, authoringPrompt } from "./prd.mjs"; import { loginAuto, whoami, logout } from "./auth.mjs"; +import { startAutoSync } from "./autosync.mjs"; import { loadCommand, saveCommand } from "./settings-sync.mjs"; import { createMirror, pressKey, teeOutput } from "./mirror.mjs"; import { fetchMotdAd } from "./ads.mjs"; @@ -822,6 +823,14 @@ export async function tui() { const { restoreTee, drainRemote, atPrompt } = await startMirror(); + // Settings sync, unattended. Started per `tui()` call and stopped in the + // teardown below, because the pit is re-entered after an engine session + // (`backToPit`) and a timer left behind would be joined by another one. + // Deliberately holds no reference to `rl`: the loop closes and rebuilds it + // around a dozen commands, so a tick that captured it would be writing to a + // readline that no longer exists. + const stopAutoSync = startAutoSync(); + let rl = mkrl(); // An alias expands into a line that is dispatched exactly as if it had been // typed, so it goes back through the top of this loop instead of through a @@ -1252,6 +1261,7 @@ export async function tui() { : `unknown command "${line}". /help for the list.`)); } + stopAutoSync(); try { rl.close(); } catch { /* noop */ } saveHistory(); console.log("\n" + ash("code hard, mosh harder. 🤘")); diff --git a/test/autosync.test.mjs b/test/autosync.test.mjs new file mode 100644 index 00000000..ff858cf4 --- /dev/null +++ b/test/autosync.test.mjs @@ -0,0 +1,255 @@ +// Automatic settings sync. +// +// The thing worth testing here is not that a timer fires — it is that an +// unattended sync can never do something a person would have refused. Every +// test below is a version of that: never --force, never a word when nothing +// happened, never a second tick on top of a running one, and never a push on +// top of an account this machine could not read. +import assert from "node:assert/strict"; +import test from "node:test"; +import { + DEFAULT_INTERVAL_MS, + MIN_INTERVAL_MS, + autoSyncEnabled, + autoSyncInterval, + startAutoSync, + syncOnce, +} from "../src/autosync.mjs"; + +const CREDS = { api: "https://app.test", token: "mck_test", email: "a@b.c" }; + +/** A stand-in for loadCommand/saveCommand: answers with one JSON body. */ +function verb(body, code = 0) { + const calls = []; + const impl = async (argv = [], opts = {}) => { + calls.push({ argv: [...argv], opts }); + opts.write?.(JSON.stringify(body)); + return code; + }; + impl.calls = calls; + return impl; +} + +/** Collects the lines a tick decided were worth showing the operator. */ +function lines() { + const out = []; + const write = (line) => out.push(String(line)); + write.out = out; + write.text = () => out.join("\n"); + return write; +} + +test("a logged-out pit syncs nothing and says nothing", async () => { + const load = verb({ status: "loaded" }); + const save = verb({ status: "saved" }); + const write = lines(); + + const result = await syncOnce({ load, save, creds: null, write }); + + assert.equal(result.skipped, "not_logged_in"); + assert.equal(load.calls.length, 0, "must not call /load without a token"); + assert.equal(save.calls.length, 0, "must not call /save without a token"); + assert.deepEqual(write.out, [], "a pit that never logged in is never nagged"); +}); + +test("a tick loads before it saves", async () => { + const order = []; + const load = async (argv, opts) => { order.push("load"); opts.write('{"status":"unchanged"}'); return 0; }; + const save = async (argv, opts) => { order.push("save"); opts.write('{"status":"unchanged"}'); return 0; }; + + await syncOnce({ load, save, creds: CREDS, write: lines() }); + + assert.deepEqual(order, ["load", "save"]); +}); + +test("neither verb is ever handed --force", async () => { + const load = verb({ status: "loaded", files: ["aliases.json"], revision: 4 }); + const save = verb({ status: "saved", revision: 5 }); + + await syncOnce({ load, save, creds: CREDS, write: lines() }); + + for (const call of [...load.calls, ...save.calls]) { + assert.ok(!call.argv.includes("--force"), `--force leaked into ${JSON.stringify(call.argv)}`); + assert.ok(call.argv.includes("--json"), "answers are read as data, not prose"); + } +}); + +test("local edits that /load declined are pushed by the /save that follows", async () => { + // The heart of it. `/load` refusing is the healthy case — you changed an + // alias and have not saved it — and the save behind it is what carries it up. + const load = verb({ status: "local_changes", files: ["aliases.json"], revision: 9 }, 1); + const save = verb({ status: "saved", revision: 10 }); + const write = lines(); + + const result = await syncOnce({ load, save, creds: CREDS, write }); + + assert.equal(result.load, "local_changes"); + assert.equal(save.calls.length, 1, "a declined /load must not stop the /save"); + assert.match(write.text(), /revision 10/); +}); + +test("a tick that changed nothing prints nothing", async () => { + const load = verb({ status: "unchanged", revision: 7 }); + const save = verb({ status: "unchanged", revision: 7 }); + const write = lines(); + + await syncOnce({ load, save, creds: CREDS, write }); + + assert.deepEqual(write.out, [], "five minutes of silence beats a line saying nothing happened"); +}); + +test("settings arriving from another machine are announced", async () => { + const load = verb({ status: "loaded", files: ["aliases.json", "news.opml"], revision: 12, from: "thinkpad" }); + const save = verb({ status: "unchanged", revision: 12 }); + const write = lines(); + + await syncOnce({ load, save, creds: CREDS, write }); + + const text = write.text(); + assert.match(text, /thinkpad/, "say which machine changed your aliases"); + assert.match(text, /2 files/); + assert.match(text, /revision 12/); +}); + +test("one file reads as a file, not 1 files", async () => { + const load = verb({ status: "loaded", files: ["aliases.json"], revision: 3, from: "desktop" }); + const save = verb({ status: "unchanged" }); + const write = lines(); + + await syncOnce({ load, save, creds: CREDS, write }); + + assert.match(write.text(), /1 file changed/); +}); + +test("a conflict is reported once, with both ways out, and never forced", async () => { + const load = verb({ status: "unchanged", revision: 4 }); + const save = verb({ status: "conflict", revision: 5, mine: 4 }, 1); + const write = lines(); + + const result = await syncOnce({ load, save, creds: CREDS, write }); + + assert.equal(result.save, "conflict"); + assert.match(write.text(), /another machine saved first/); + assert.match(write.text(), /--force/, "name the escape hatch without taking it"); + assert.equal(save.calls.length, 1, "a conflict is a decision, not something to retry"); +}); + +test("rejected credentials stop the tick before it pushes", async () => { + const load = verb({ status: "expired" }, 1); + const save = verb({ status: "saved", revision: 2 }); + const write = lines(); + + await syncOnce({ load, save, creds: CREDS, write }); + + assert.equal(save.calls.length, 0, "never push on top of an account we could not read"); + assert.match(write.text(), /`\/login`/); +}); + +test("a network failure is silent", async () => { + const load = verb({ status: "failed", error: "getaddrinfo ENOTFOUND" }, 1); + const save = verb({ status: "failed", error: "getaddrinfo ENOTFOUND" }, 1); + const write = lines(); + + await syncOnce({ load, save, creds: CREDS, write }); + + assert.deepEqual(write.out, [], "a laptop on a train must not narrate every tunnel"); +}); + +test("MOSHCODE_NO_AUTOSYNC turns it off and starts no timer", () => { + assert.equal(autoSyncEnabled({}), true, "on by default"); + assert.equal(autoSyncEnabled({ MOSHCODE_NO_AUTOSYNC: "1" }), false); + + let started = 0; + const stop = startAutoSync({ + enabled: false, + timers: { setInterval: () => { started++; return 1; }, clearInterval: () => {} }, + }); + + assert.equal(started, 0); + assert.equal(typeof stop, "function", "the off switch still returns something safe to call"); + stop(); +}); + +test("the interval defaults to five minutes and floors a silly one", () => { + assert.equal(autoSyncInterval({}), DEFAULT_INTERVAL_MS); + assert.equal(autoSyncInterval({ MOSHCODE_AUTOSYNC_MS: "60000" }), 60000); + assert.equal(autoSyncInterval({ MOSHCODE_AUTOSYNC_MS: "1" }), MIN_INTERVAL_MS, "1ms is a typo, not a request"); + assert.equal(autoSyncInterval({ MOSHCODE_AUTOSYNC_MS: "nonsense" }), DEFAULT_INTERVAL_MS); + assert.equal(autoSyncInterval({ MOSHCODE_AUTOSYNC_MS: "-5" }), DEFAULT_INTERVAL_MS); +}); + +test("the timer never holds the pit open, and stops when the pit does", () => { + let unreffed = false; + let cleared = null; + const handle = { unref: () => { unreffed = true; } }; + + const stop = startAutoSync({ + creds: CREDS, + timers: { + setInterval: () => handle, + clearInterval: (h) => { cleared = h; }, + }, + }); + + assert.equal(unreffed, true, "a pending tick must not delay `/quit`"); + stop(); + assert.equal(cleared, handle); +}); + +test("ticks are single-flight — a slow sync is skipped, not stacked", async () => { + let running = 0; + let peak = 0; + let release; + const gate = new Promise((resolve) => { release = resolve; }); + + let tick; + const stop = startAutoSync({ + creds: CREDS, + load: async (argv, opts) => { + running++; + peak = Math.max(peak, running); + await gate; + opts.write('{"status":"unchanged"}'); + running--; + return 0; + }, + save: verb({ status: "unchanged" }), + timers: { setInterval: (fn) => { tick = fn; return { unref() {} }; }, clearInterval: () => {} }, + }); + + const first = tick(); + const second = tick(); + release(); + await Promise.all([first, second]); + + assert.equal(peak, 1, "two ticks must never write the same files at once"); + stop(); +}); + +test("a tick that throws does not take the pit down", async () => { + let tick; + const stop = startAutoSync({ + creds: CREDS, + load: async () => { throw new Error("the app fell over"); }, + timers: { setInterval: (fn) => { tick = fn; return { unref() {} }; }, clearInterval: () => {} }, + }); + + await assert.doesNotReject(() => tick()); + stop(); +}); + +test("a tick fired after stop() does nothing", async () => { + const load = verb({ status: "unchanged" }); + let tick; + const stop = startAutoSync({ + creds: CREDS, + load, + save: verb({ status: "unchanged" }), + timers: { setInterval: (fn) => { tick = fn; return { unref() {} }; }, clearInterval: () => {} }, + }); + + stop(); + await tick(); + + assert.equal(load.calls.length, 0, "a stopped sync stays stopped even if the timer fires once more"); +}); From 496f2586e87ae5d2c8b49bd0d7f077917bcecc57 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 16:39:36 +0000 Subject: [PATCH 2/3] Take the local root back out on `dns disable` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dns enable` installs a CA as a system trust anchor, and nothing ever removed it. The restore point it writes is the right shape for resolver config — files in /etc, captured and replayed — and the wrong shape for a trust store, so the one change with security consequences was the one `disable` left behind. A machine that ran `dns enable` once kept trusting a locally generated root forever, including after `dns disable` reported the machine was as it was. Removal is deliberately not `trustPlan(...).steps.reverse()`. Installing is gated on the root being safe to install — name constraints, a certificate that parses — and none of that has any bearing on taking it back out: a root that should never have been trusted is the one you most need to be able to withdraw. So untrustPlan asks two questions only, whether the store can be reached and whether the undo needs the certificate file, and never refuses. The three stores do not undo alike. NSS forgets by nickname and the Debian store by filename, so both still work after the root itself is deleted, which is the ordinary case — someone who wants rid of this removes the certificate first and asks questions after. The macOS keychain can only be told with the certificate in hand, and that is the case that has to say so out loud rather than leave an anchor behind and report success. Two smaller things the shape forced. `update-ca-certificates --fresh` rather than a bare refresh: the bare form adds what is new, and it is the rebuild that drops the symlink for a source file that is no longer there. And certutil's complaint that the nickname is not in the database describes the state we were trying to reach, so it reads as "was not there" rather than FAIL — running `dns disable` twice is ordinary. `--keep-trust` is the symmetry with enable's `--no-trust`, for turning resolution off for an afternoon without paying to install the root again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XGe9mWC6FvUjT6p1HCVjba --- src/dns.mjs | 13 ++- src/trust.mjs | 148 ++++++++++++++++++++++++++++++++++ test/untrust.test.mjs | 181 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 test/untrust.test.mjs diff --git a/src/dns.mjs b/src/dns.mjs index cd8fb876..f195e79c 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -2274,7 +2274,7 @@ import { createParkingServer, DEFAULT_PARKING_HTTP_PORT } from "./parking-http.m // use it without importing this one back. export { pitNameUrl } from "./pit-url.mjs"; import { pitNameUrl } from "./pit-url.mjs"; -import { applyTrust, createAutoTrust, trustName, verifyStockTls } from "./trust.mjs"; +import { applyTrust, applyUntrust, createAutoTrust, trustName, verifyStockTls } from "./trust.mjs"; import { readFile, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -2326,6 +2326,9 @@ const USAGE = `moshcode dns — resolve Moshpit names on this machine --no-trust with enable: route names but skip the local CA. They will resolve and then fail TLS, which is the state this flag exists to leave you in deliberately. + --keep-trust with disable: put the routing back but leave the local CA in + your trust store. For turning resolution off for an afternoon + without paying to install the root again afterwards. --no-proxy with enable: answer each name's origin rather than the local pinned-TLS proxy. Only the proxy can hand a stock client a certificate it will accept, so this is the other half of the @@ -2956,6 +2959,14 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { const cleared2 = await applyPlan({ steps: [{ kind: "remove", path: manifestFile, why: "the restore point has been used" }] }); if (cleared2.ok) out(` ok remove ${manifestFile}`); } + + // The routing is back, which leaves the trust anchor as the last thing + // `enable` did that is still on this machine — and it was the one change + // the restore point never covered, because it is not a file in /etc. + // Removing it by default is what makes `disable` mean "as it was". + // `--keep-trust` is for turning resolution off for an afternoon without + // paying for a re-install of the root afterwards. + if (!rest.includes("--keep-trust")) await applyUntrust(out, deps); out(""); // The line the old implementation printed unconditionally, now only when diff --git a/src/trust.mjs b/src/trust.mjs index fc4c3c39..48e8cbc9 100644 --- a/src/trust.mjs +++ b/src/trust.mjs @@ -189,6 +189,15 @@ export function trustStores({ platform = process.platform, home = os.homedir(), needsRoot: true, command: "security", args: ["add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", file], + // The one store that can only be told with the certificate in hand: + // `remove-trusted-cert` takes a file, not a nickname. `needsFile` is what + // lets the undo say so out loud instead of leaving an anchor behind and + // reporting success. + remove: { + needsFile: true, + command: "security", + args: ["remove-trusted-cert", "-d", file], + }, }); return stores; } @@ -207,6 +216,13 @@ export function trustStores({ platform = process.platform, home = os.homedir(), ownedDir: path.join(home, ".pki", "nssdb"), command: "certutil", args: ["-d", `sql:${path.join(home, ".pki", "nssdb")}`, "-A", "-t", "C,,", "-n", "Moshpit Local CA", "-i", file], + // By nickname, so the anchor can still be withdrawn after the root file + // itself is gone — which is the ordinary case, since a person who wants + // rid of this deletes the certificate first and asks questions after. + remove: { + command: "certutil", + args: ["-d", `sql:${path.join(home, ".pki", "nssdb")}`, "-D", "-n", "Moshpit Local CA"], + }, }); stores.push({ id: "system", @@ -218,6 +234,14 @@ export function trustStores({ platform = process.platform, home = os.homedir(), copyTo: "/usr/local/share/ca-certificates/moshpit-local-ca.crt", command: "update-ca-certificates", args: [], + // Delete the copy, then rebuild. `--fresh` rather than a bare refresh: + // the bare form adds what is new, and it is the rebuild that drops the + // symlink for a source file that is no longer there. + remove: { + removeFile: "/usr/local/share/ca-certificates/moshpit-local-ca.crt", + command: "update-ca-certificates", + args: ["--fresh"], + }, }); return stores; } @@ -278,6 +302,130 @@ export function trustPlan({ return { ok: true, steps, skipped, file, why: constrained.why }; } +/** + * What `dns disable` should do about trust. + * + * Deliberately not `trustPlan(...).steps.reverse()`. Installing is gated on the + * root being safe to install — name constraints, a certificate that parses — + * and none of that has any bearing on taking it back out: a root that should + * never have been trusted is the *most* important one to be able to withdraw. + * So this plan asks two questions only, whether the store can be reached and + * whether the undo needs the certificate file, and never refuses. + * + * Pure, like trustPlan, so `dns disable` can be tested without a trust store. + */ +export function untrustPlan({ + platform = process.platform, + home = os.homedir(), + caFile = null, + isRoot = false, + haveCertutil = true, + haveFile = true, +} = {}) { + const file = caFile || caPath({ home }); + const steps = []; + const skipped = []; + + for (const store of trustStores({ platform, home, caFile: file })) { + if (!store.remove) { + skipped.push({ ...store, why: "this build knows how to install it but not how to remove it" }); + continue; + } + if (store.id === "nss" && !haveCertutil) { + skipped.push({ ...store, why: "certutil is not installed (Debian/Ubuntu: libnss3-tools)" }); + continue; + } + if (store.needsRoot && !isRoot) { + skipped.push({ ...store, why: "needs root" }); + continue; + } + // The macOS case. Saying "the root is gone, so the anchor cannot be named" + // is worth a line, because the alternative is a machine that keeps trusting + // a certificate nobody can produce any more. + if (store.remove.needsFile && !haveFile) { + skipped.push({ ...store, why: `the root at ${file} is gone, and this store can only be told with it` }); + continue; + } + steps.push(store); + } + + return { ok: true, steps, skipped, file }; +} + +/** + * The trust half of `dns disable` — take back what `applyTrust` installed. + * + * Non-fatal throughout, for the same reason its counterpart is: resolution has + * already been put back by the time this runs, and failing the whole command + * over a trust store would undo working DNS to fix a certificate. What it must + * not do is report a removal it did not achieve, since a trust anchor believed + * gone is worse than one known to be present. + */ +export async function applyUntrust(out, deps = {}) { + const { + readFile = async (f) => (await import("node:fs/promises")).readFile(f, "utf8"), + runner = run, + env = process.env, + home = operatorHome({ env }), + platform = process.platform, + uid = typeof process.getuid === "function" ? process.getuid() : 0, + } = deps; + const owner = env.SUDO_USER || env.DOAS_USER || null; + + const file = caPath({ home }); + const haveFile = await readFile(file).then(() => true, () => false); + const plan = untrustPlan({ + platform, home, caFile: file, isRoot: uid === 0, + haveCertutil: (await runner("which", ["certutil"])).ok, + haveFile, + }); + + if (!plan.steps.length && !plan.skipped.length) return { ok: true, removed: 0, skipped: 0 }; + + out(""); + out("trust (taking the local root back out)"); + + let removed = 0; + for (const step of plan.steps) { + const undo = step.remove; + if (undo.removeFile) { + // `rm -f`: the copy not being there is the state we are trying to reach, + // so its absence is success rather than something to report. + const gone = await runner("rm", ["-f", undo.removeFile]); + if (!gone.ok) { + out(` FAIL ${step.label} — ${gone.stderr.split("\n")[0] || `could not remove ${undo.removeFile}`}`); + continue; + } + } + const done = await runner(undo.command, undo.args); + if (!done.ok) { + const first = done.stderr.split("\n")[0] || ""; + // certutil says this when the nickname is not in the database, which is + // the same end state as a successful removal and must not read as a + // failure — most often it means `dns disable` is being run twice. + if (/SEC_ERROR_BAD_DATA|not found|PR_FILE_NOT_FOUND/i.test(first)) { + out(` ok ${step.label} — was not there`); + continue; + } + out(` FAIL ${step.label} — ${first || `${undo.command} failed`}`); + continue; + } + if (step.ownedDir && owner && uid === 0) { + const owned = await runner("chown", ["-R", `${owner}:`, step.ownedDir]); + if (!owned.ok) out(` -- ${step.ownedDir} is left owned by root — chown -R ${owner}: ${step.ownedDir}`); + } + removed++; + out(` ok removed from ${step.label}`); + } + + for (const step of plan.skipped) { + out(` -- ${step.label} — ${step.why}`); + if (step.needsRoot && uid !== 0) out(" re-run with root to cover it: sudo moshcode dns disable"); + } + + return { ok: true, removed, skipped: plan.skipped.length }; +} + /** * What to do about a refusal, which depends entirely on which one it is. * diff --git a/test/untrust.test.mjs b/test/untrust.test.mjs new file mode 100644 index 00000000..16a051fd --- /dev/null +++ b/test/untrust.test.mjs @@ -0,0 +1,181 @@ +/** + * Taking the local root back out — the undo `dns disable` never had. + * + * `dns enable` installs a CA as a system trust anchor. The restore point it + * writes covers /etc, which is the right shape for resolver config and the + * wrong shape for this: a trust store is not a file the manifest can capture + * and replay. So for a long time `dns disable` put the routing back and left + * behind the one change with security consequences. + * + * The property every test here is a version of: a trust anchor reported as + * removed must actually be gone, and one that could not be removed must say so + * rather than be quietly counted as success. A machine that believes it stopped + * trusting a root it still trusts is worse off than one that knows it does. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; + +import { applyUntrust, trustStores, untrustPlan } from "../src/trust.mjs"; + +const HOME = "/home/tester"; +const CA = path.join(HOME, ".moshpit", "ca.crt"); + +/** Collects the report, the way the dns command's `out` does. */ +function out() { + const lines = []; + const write = (line) => lines.push(String(line)); + write.text = () => lines.join("\n"); + write.lines = lines; + return write; +} + +/** A runner that succeeds at everything and remembers what it was asked. */ +function runner(overrides = {}) { + const calls = []; + const impl = async (command, args) => { + calls.push({ command, args: [...args], line: `${command} ${args.join(" ")}` }); + const hit = overrides[command]; + if (typeof hit === "function") return hit(args); + return hit || { ok: true, stdout: "", stderr: "" }; + }; + impl.calls = calls; + return impl; +} + +test("every store this build can install into, it can also remove from", () => { + for (const platform of ["linux", "darwin"]) { + for (const store of trustStores({ platform, home: HOME, caFile: CA })) { + assert.ok(store.remove, `${platform}/${store.id} can be installed but never removed`); + assert.ok(store.remove.command, `${platform}/${store.id} has no removal command`); + } + } +}); + +test("removal is not gated on the root being safe to install", () => { + // The point: a root that should never have been trusted is the one you most + // need to be able to withdraw. untrustPlan takes no certificate at all. + const plan = untrustPlan({ platform: "linux", home: HOME, caFile: CA, isRoot: true }); + + assert.equal(plan.ok, true); + assert.equal(plan.steps.length, 2, "nss and the system store"); + assert.deepEqual(plan.skipped, []); +}); + +test("the NSS anchor is withdrawn by nickname, so a deleted root is no obstacle", () => { + const plan = untrustPlan({ + platform: "linux", home: HOME, caFile: CA, isRoot: true, haveFile: false, + }); + + const nss = plan.steps.find((s) => s.id === "nss"); + assert.ok(nss, "nss must still be removable with the certificate gone"); + assert.ok(nss.remove.args.includes("-D"), "delete, not add"); + assert.ok(nss.remove.args.includes("Moshpit Local CA")); + assert.ok(!nss.remove.args.includes(CA), "must not need the file it no longer has"); +}); + +test("the macOS keychain says so when the root it needs is gone", () => { + const withFile = untrustPlan({ platform: "darwin", home: HOME, caFile: CA, isRoot: true, haveFile: true }); + assert.equal(withFile.steps.length, 1); + + const without = untrustPlan({ platform: "darwin", home: HOME, caFile: CA, isRoot: true, haveFile: false }); + assert.equal(without.steps.length, 0, "cannot be done"); + assert.equal(without.skipped.length, 1, "and must not be silent about it"); + assert.match(without.skipped[0].why, /can only be told with it/); +}); + +test("the system store is skipped without root, and names the fix", async () => { + const plan = untrustPlan({ platform: "linux", home: HOME, caFile: CA, isRoot: false }); + + assert.deepEqual(plan.steps.map((s) => s.id), ["nss"], "the user-level half still works"); + assert.deepEqual(plan.skipped.map((s) => s.id), ["system"]); + + const write = out(); + await applyUntrust(write, { + platform: "linux", home: HOME, uid: 1000, env: {}, + readFile: async () => "cert", + runner: runner(), + }); + assert.match(write.text(), /sudo moshcode dns disable/); +}); + +test("the Debian copy is deleted before the bundle is rebuilt", async () => { + const write = out(); + const run = runner(); + + await applyUntrust(write, { + platform: "linux", home: HOME, uid: 0, env: {}, + readFile: async () => "cert", + runner: run, + }); + + const rm = run.calls.findIndex((c) => c.command === "rm"); + const refresh = run.calls.findIndex((c) => c.command === "update-ca-certificates"); + assert.ok(rm !== -1, "the copy in /usr/local/share/ca-certificates must be removed"); + assert.ok(refresh > rm, "removing the source before the rebuild is what drops the symlink"); + assert.ok(run.calls[refresh].args.includes("--fresh"), "a bare refresh adds, it does not drop"); + assert.match(write.text(), /removed from the system store/); +}); + +test("an anchor that was already gone reads as done, not as a failure", async () => { + // Running `dns disable` twice is ordinary. certutil's complaint that the + // nickname is not in the database describes the state we wanted. + const write = out(); + const run = runner({ + certutil: () => ({ ok: false, stdout: "", stderr: "certutil: could not find certificate named...: SEC_ERROR_BAD_DATA" }), + }); + + const result = await applyUntrust(write, { + platform: "linux", home: HOME, uid: 0, env: {}, + readFile: async () => "cert", + runner: run, + }); + + assert.equal(result.ok, true); + assert.match(write.text(), /was not there/); + assert.ok(!/FAIL/.test(write.text()), "an already-absent anchor is not a failure"); +}); + +test("a removal that genuinely failed is reported as FAIL and not counted", async () => { + const write = out(); + const run = runner({ + "update-ca-certificates": () => ({ ok: false, stdout: "", stderr: "update-ca-certificates: permission denied" }), + }); + + const result = await applyUntrust(write, { + platform: "linux", home: HOME, uid: 0, env: {}, + readFile: async () => "cert", + runner: run, + }); + + assert.match(write.text(), /FAIL the system store/); + assert.equal(result.removed, 1, "only the NSS half succeeded"); +}); + +test("an NSS database written as root is handed back to the operator", async () => { + const write = out(); + const run = runner(); + + await applyUntrust(write, { + platform: "linux", home: HOME, uid: 0, env: { SUDO_USER: "tester" }, + readFile: async () => "cert", + runner: run, + }); + + const chown = run.calls.find((c) => c.command === "chown"); + assert.ok(chown, "root must not leave the browser unable to update its own store"); + assert.deepEqual(chown.args, ["-R", "tester:", path.join(HOME, ".pki", "nssdb")]); +}); + +test("nothing to remove prints nothing at all", async () => { + const write = out(); + + const result = await applyUntrust(write, { + platform: "sunos", home: HOME, uid: 0, env: {}, + readFile: async () => "cert", + runner: runner(), + }); + + assert.equal(result.removed, 0); + assert.deepEqual(write.lines, [], "a platform with no known store is not an occasion for a heading"); +}); From 9ef38e6e8a8a307e5190879fca522a20b057d8ee Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 16:41:47 +0000 Subject: [PATCH 3/3] Carry the rest of the settings, and enforce the exclusions that were only comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/save` carried four files. Everything else moshcode lets you configure — herd's notification preferences, the per-model prices you typed into `pricing.json` by hand, the DNS filter's categories and your own allow and block lists, and the clients, teams, rates and invoices in `business.json` — started from nothing on a new machine. `herd/config.json` was the clearest miss: it sat next to `rules.json`, which has been synced since the first version, so "my herd settings came across" was true of half of them. Picking what to add meant walking everything moshcode writes, and most of it must never move. A blocked-domain list is browsing history. A task ledger carries prompt text and output. A transcript is a full screen capture of a session. `timers.json` is a work ledger, and two machines appending hours under a last-write-wins sync is how you lose an afternoon's entries. All of those are now named. Two of the exclusions did not work. `NEVER_SYNCED` is an exact-string match, so naming a directory in it would have been inert — the entries under it would not match and would fall through — which is why the subtree rules are a separate prefix list. And the header comment has claimed since the first version that `*.sock` and `*.pid` are excluded "because they describe processes on exactly one box"; that was true of the intent and enforced by nothing, with the allowlist quietly doing the work. It is a rule now, and `.transcript` is on it. `business.json` goes last on purpose. The 256 KiB total is spent in allowlist order, so the file most likely to grow past it has to sort after the small ones — otherwise a year of invoices silently pushes your aliases out of the snapshot instead of being skipped itself. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XGe9mWC6FvUjT6p1HCVjba --- README.md | 33 +++++- prd/0010-cloud-settings-sync.md | 14 ++- src/settings-sync.mjs | 74 +++++++++++++- test/settings-sync-allowlist.test.mjs | 138 ++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 test/settings-sync-allowlist.test.mjs diff --git a/README.md b/README.md index 0f14327d..0f653038 100644 --- a/README.md +++ b/README.md @@ -1179,12 +1179,35 @@ What syncs is an allowlist, not a directory walk: |---|---| | `~/.moshcode/aliases.json` | your pit aliases (`/alias`) | | `~/.moshcode/herd/rules.json` | herd state-detection overrides | +| `~/.moshcode/herd/config.json` | herd notification preferences | +| `~/.moshcode/feeds.opml` | your `tcfeed` rss feeds | +| `~/.moshcode/news.opml` | `/news` and `/rss` subscriptions | +| `~/.moshcode/pricing.json` | per-model price overrides for `/cost` | +| `~/.moshcode/dns-filter/filter.json` | dns filter categories and your allow/block lists | +| `~/.moshcode/business.json` | clients, teams, rates and invoices | -What never syncs, by name: `credentials.json` (the account token this very -feature authenticates with), `herd/sessions.json` (live state pinned to one tmux -server), `sync.json`, and the `pkg/` binary cache. Engine configuration -(`~/.claude.json` and friends) is deliberately left alone — those files carry -provider API keys. +What never syncs, and why: + +| not carried | because | +|---|---| +| `credentials.json` | the account token this very feature authenticates with | +| `herd/sessions.json`, `herd/tasks/`, `*.transcript` | live state, prompt text and full screen captures of your sessions | +| `dns-filter/stats.json` | it records the last domains you were blocked from reaching | +| `timers.json` | a running work ledger; two machines appending would lose entries | +| `news-last.json`, `news-found.json`, `lists/`, `dns-filter/lists/` | caches that refill themselves | +| `moshpit-dns.pid`, `*.log`, `*.sock` | one box's daemon | +| `sync.json`, `pkg/` | this feature's own bookkeeping, and moshcode itself | + +Engine configuration (`~/.claude.json` and friends) is deliberately left alone — +those files carry provider API keys. So is `~/.moshcode_history`, which holds +whatever you typed at the prompt. + +Two things worth knowing about the caps. A file over 64 KiB is reported as +skipped rather than failing the save, so a `business.json` that grew a year of +invoices stops being carried and says so. And the 256 KiB total is spent in the +order of the table above, which is why the file most likely to grow is last — +otherwise it would push your aliases out of the snapshot rather than being +skipped itself. Nothing is overwritten quietly: diff --git a/prd/0010-cloud-settings-sync.md b/prd/0010-cloud-settings-sync.md index ae3a4e65..a8e31336 100644 --- a/prd/0010-cloud-settings-sync.md +++ b/prd/0010-cloud-settings-sync.md @@ -67,9 +67,17 @@ configuration is already there and already paired with every machine. - R1 [P0] `/save` (and `moshcode save`) uploads this machine's pit settings to the logged-in account. `/load` (`moshcode load`) brings them back down. -- R2 [P0] What syncs is an allowlist, not a directory walk: `aliases.json` and - `herd/rules.json` today. `credentials.json`, `herd/sessions.json`, `sync.json` - and `pkg/` are named as never-synced and asserted in tests. +- R2 [P0] What syncs is an allowlist, not a directory walk: the pit's settings + (`aliases.json`), herd's (`herd/rules.json`, `herd/config.json`), the feed and + news subscriptions, `pricing.json`, the DNS filter's policy, and + `business.json`. `~/.moshcode` is also where moshcode installs itself and + where the account token lives, so the allowlist is load-bearing rather than + tidy. `credentials.json`, `herd/sessions.json`, `sync.json` and `pkg/` are + named as never-synced and asserted in tests, alongside the state that is + meaningless or private off its own machine: task ledgers and transcripts, + `timers.json`, `dns-filter/stats.json` (a list of blocked domains is browsing + history), listing caches, and one box's pidfiles and logs. Directory and + extension rules are enforced, not only documented. - R3 [P0] Each save is a numbered revision. `/save` sends the revision it last agreed on and the app refuses the write if the account has moved past it, so two machines cannot silently erase one another. diff --git a/src/settings-sync.mjs b/src/settings-sync.mjs index f018b80e..24a1bd63 100644 --- a/src/settings-sync.mjs +++ b/src/settings-sync.mjs @@ -74,6 +74,28 @@ export const SYNCED_FILES = [ // reported as skipped rather than failing the snapshot, which is the right // answer for a list that got big by importing somebody else's. { path: "news.opml", json: false, label: "news subscriptions" }, + // Herd's notification preferences — which states nag you and whether they + // ask. `rules.json` above has been carried since the first version and this + // sat next to it, unsynced, which made "my herd settings came across" true + // of half of them. + { path: "herd/config.json", json: true, label: "herd notifications" }, + // Per-model price overrides for `/cost`. Nothing writes this file; a person + // types it, once, from a pricing page — which is exactly the kind of work + // `/save` exists so you only do once. + { path: "pricing.json", json: true, label: "cost model prices" }, + // The DNS filter's policy: categories, and your own allow and block lists. + // A curated decision, not machine state — the blocklists it names are a + // cache that re-downloads itself, and `dns-filter/stats.json` next to it is + // browsing history and named below as never-synced. + { path: "dns-filter/filter.json", json: true, label: "dns filter policy" }, + // Clients, teams, rates, invoices. The largest single "start from nothing" + // on a new machine, and safe to carry because payment gateways are stored + // here as references into a vault rather than as keys (see payments.mjs). + // + // Last on purpose. The total cap is spent in this order, so the file most + // likely to grow past it goes after the small ones — otherwise a year of + // invoices silently pushes your aliases out of the snapshot. + { path: "business.json", json: true, label: "clients, rates and invoices" }, ]; /** @@ -91,13 +113,63 @@ export const NEVER_SYNCED = [ "sync.json", "herd/sessions.json", "herd/hook.json", + // A billing ledger, not a preference. Two machines both appending hours and + // then both saving means the later `/save` drops the earlier one's entries, + // and it grows without bound — the two properties that make a file wrong for + // a last-write-wins sync. + "timers.json", + // "the numbers you last saw here", so that `add 3` means something. Carrying + // them would make `add 3` on another machine refer to a listing it never saw. + "news-last.json", + "news-found.json", + // Counters, and `recent[]` — the last twenty domains this machine was + // blocked from reaching. That is browsing history, and it has no business in + // a settings snapshot even one belonging to the person who generated it. + "dns-filter/stats.json", + // One box's daemon. + "moshpit-dns.pid", + "moshpit-dns.log", +]; + +/** + * Whole subtrees that never sync, matched by prefix. + * + * `NEVER_SYNCED` is an exact-string list, so a directory named there would be + * inert — the entries under it would not match it and would fall through. Any + * rule about a directory has to live here to have an effect. + */ +export const NEVER_SYNCED_PREFIXES = [ + // The program itself. ~/.moshcode is also the install directory. + "pkg/", + // Hook reports, remote polls and per-session task ledgers: all pinned to one + // runtime, and the task ledgers carry prompt text and output artifacts. + "herd/status/", + "herd/remote/", + "herd/tasks/", + // Cached copies of published feed lists, re-fetched on demand. + "lists/", + // Downloaded blocklists. Megabytes, and self-renewing. + "dns-filter/lists/", ]; +/** + * The pty substrate, by extension. + * + * The header comment above has claimed since the first version that `*.sock` + * and `*.pid` are excluded "because they describe processes on exactly one + * box". That was true of the intent and never of the code: nothing enforced + * it, and the allowlist alone happened to be doing the work. Now it is a rule. + * A transcript is the one that matters — it is a full screen capture and will + * hold whatever was typed into that session. + */ +export const NEVER_SYNCED_SUFFIXES = [".transcript", ".stdin", ".exit", ".sock", ".pid", ".log"]; + /** True for a path this build is willing to read or write. */ export function isSyncable(relative) { const name = String(relative ?? ""); if (NEVER_SYNCED.includes(name)) return false; - if (name.startsWith("pkg/")) return false; + if (NEVER_SYNCED_PREFIXES.some((prefix) => name.startsWith(prefix))) return false; + if (NEVER_SYNCED_SUFFIXES.some((suffix) => name.endsWith(suffix))) return false; return SYNCED_FILES.some((f) => f.path === name); } diff --git a/test/settings-sync-allowlist.test.mjs b/test/settings-sync-allowlist.test.mjs new file mode 100644 index 00000000..aac79d9d --- /dev/null +++ b/test/settings-sync-allowlist.test.mjs @@ -0,0 +1,138 @@ +/** + * What `/save` carries, and — more importantly — what it must never carry. + * + * `~/.moshcode` is not only the settings directory: it is also where moshcode + * installs itself, where the account token lives, where the herd keeps full + * screen captures of every session, and where the DNS filter records the last + * domains this machine was blocked from reaching. The allowlist is the only + * thing standing between "sync my settings" and "upload my history and my + * credentials", which is why it is an allowlist and why these tests are about + * the denials rather than the inclusions. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + NEVER_SYNCED, + NEVER_SYNCED_PREFIXES, + NEVER_SYNCED_SUFFIXES, + SYNCED_FILES, + collectSnapshot, + isSyncable, +} from "../src/settings-sync.mjs"; + +test("everything on the allowlist is syncable, and nothing else is", () => { + for (const entry of SYNCED_FILES) { + assert.equal(isSyncable(entry.path), true, `${entry.path} is listed but refused`); + } + assert.equal(isSyncable("aliases.json.bak"), false); + assert.equal(isSyncable("../../.ssh/authorized_keys"), false); + assert.equal(isSyncable(""), false); + assert.equal(isSyncable(null), false); +}); + +test("no path is both allowed and denied", () => { + // A future edit that adds a convenient-looking entry to SYNCED_FILES without + // noticing it is already denied would produce a file that is listed, never + // sent, and never explained. + for (const entry of SYNCED_FILES) { + assert.ok(!NEVER_SYNCED.includes(entry.path), `${entry.path} is on both lists`); + for (const prefix of NEVER_SYNCED_PREFIXES) { + assert.ok(!entry.path.startsWith(prefix), `${entry.path} is denied by prefix ${prefix}`); + } + for (const suffix of NEVER_SYNCED_SUFFIXES) { + assert.ok(!entry.path.endsWith(suffix), `${entry.path} is denied by suffix ${suffix}`); + } + } +}); + +test("the credential this feature authenticates with never syncs", () => { + assert.equal(isSyncable("credentials.json"), false); +}); + +test("every named never-synced file is refused", () => { + for (const name of NEVER_SYNCED) { + assert.equal(isSyncable(name), false, `${name} is named as never-synced but allowed`); + } +}); + +test("a directory rule actually excludes what is under it", () => { + // NEVER_SYNCED is an exact-string match, so a directory named there would be + // inert. These paths are only refused if the prefix list is doing its job. + assert.equal(isSyncable("herd/tasks/mosh-1.jsonl"), false, "task ledgers carry prompt text"); + assert.equal(isSyncable("herd/tasks/seq"), false); + assert.equal(isSyncable("herd/status/claude.json"), false); + assert.equal(isSyncable("herd/remote/desktop.json"), false); + assert.equal(isSyncable("lists/kagi-smallweb.json"), false); + assert.equal(isSyncable("dns-filter/lists/ads.txt"), false, "megabytes, and self-renewing"); + assert.equal(isSyncable("pkg/bin/moshcode.mjs"), false, "the program is not a setting"); +}); + +test("the pty substrate is excluded by extension, as the header always claimed", () => { + // This was documented from the first version and enforced by nothing. + assert.equal(isSyncable("herd/mosh-1.transcript"), false, "a full screen capture of a session"); + assert.equal(isSyncable("herd/mosh-1.stdin"), false); + assert.equal(isSyncable("herd/mosh-1.pid"), false); + assert.equal(isSyncable("herd/mosh-1.exit"), false); + assert.equal(isSyncable("herd/agent.sock"), false); + assert.equal(isSyncable("moshpit-dns.log"), false); +}); + +test("the dns filter's policy syncs and its statistics do not", () => { + // The two live side by side and one of them is browsing history. + assert.equal(isSyncable("dns-filter/filter.json"), true, "categories and your own lists are a decision"); + assert.equal(isSyncable("dns-filter/stats.json"), false, "recent[] is the last domains you were blocked from"); +}); + +test("a work ledger is not a preference", () => { + assert.equal(isSyncable("timers.json"), false, "two machines appending hours would lose entries"); +}); + +test("listing caches stay on the machine that made them", () => { + assert.equal(isSyncable("news-last.json"), false); + assert.equal(isSyncable("news-found.json"), false); +}); + +test("the file most likely to outgrow the cap is collected last", () => { + // The total cap is spent in allowlist order, so a big file placed early + // silently pushes the small ones out of the snapshot rather than being + // skipped itself. + assert.equal(SYNCED_FILES.at(-1).path, "business.json"); +}); + +test("the new settings are actually picked up from disk", () => { + const dir = mkdtempSync(path.join(tmpdir(), "moshcode-allowlist-")); + const moshcode = path.join(dir, ".moshcode"); + fs.mkdirSync(path.join(moshcode, "herd"), { recursive: true }); + fs.mkdirSync(path.join(moshcode, "dns-filter"), { recursive: true }); + + fs.writeFileSync(path.join(moshcode, "aliases.json"), JSON.stringify({ gs: "git status" })); + fs.writeFileSync(path.join(moshcode, "herd", "config.json"), JSON.stringify({ notify: { enabled: true } })); + fs.writeFileSync(path.join(moshcode, "pricing.json"), JSON.stringify({ "claude-opus-5": { input: 1 } })); + fs.writeFileSync(path.join(moshcode, "dns-filter", "filter.json"), JSON.stringify({ enabled: true, block: ["x.test"] })); + fs.writeFileSync(path.join(moshcode, "business.json"), JSON.stringify({ clients: [{ name: "acme" }] })); + + // The two that must not come along, sitting right next to ones that do. + fs.writeFileSync(path.join(moshcode, "credentials.json"), JSON.stringify({ token: "mck_secret" })); + fs.writeFileSync(path.join(moshcode, "dns-filter", "stats.json"), JSON.stringify({ recent: ["private.test"] })); + fs.writeFileSync(path.join(moshcode, "timers.json"), JSON.stringify({ entries: [] })); + + const { snapshot, included } = collectSnapshot({ home: dir, hostname: "test", version: "0.0.0", installed: {} }); + const carried = included.map((f) => f.path); + + assert.ok(carried.includes("herd/config.json")); + assert.ok(carried.includes("pricing.json")); + assert.ok(carried.includes("dns-filter/filter.json")); + assert.ok(carried.includes("business.json")); + + const serialised = JSON.stringify(snapshot); + assert.ok(!serialised.includes("mck_secret"), "the account token must never reach a snapshot"); + assert.ok(!serialised.includes("private.test"), "nor must a blocked-domain list"); + assert.ok(!carried.includes("timers.json")); + + fs.rmSync(dir, { recursive: true, force: true }); +});