diff --git a/README.md b/README.md index 6a7566b..0f65303 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: @@ -1199,6 +1222,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 c1e3d50..a8e3133 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. @@ -62,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. @@ -83,6 +96,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 0000000..31021d1 --- /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/dns.mjs b/src/dns.mjs index cd8fb87..f195e79 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/settings-sync.mjs b/src/settings-sync.mjs index f018b80..24a1bd6 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/src/trust.mjs b/src/trust.mjs index fc4c3c3..48e8cbc 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/src/tui.mjs b/src/tui.mjs index 16b3877..5561898 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 0000000..ff858cf --- /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"); +}); diff --git a/test/settings-sync-allowlist.test.mjs b/test/settings-sync-allowlist.test.mjs new file mode 100644 index 0000000..aac79d9 --- /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 }); +}); diff --git a/test/untrust.test.mjs b/test/untrust.test.mjs new file mode 100644 index 0000000..16a051f --- /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"); +});