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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
33 changes: 28 additions & 5 deletions prd/0010-cloud-settings-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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

Expand Down
175 changes: 175 additions & 0 deletions src/autosync.mjs
Original file line number Diff line number Diff line change
@@ -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 };
13 changes: 12 additions & 1 deletion src/dns.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading