Skip to content

feat(account): /save and /load — cloud sync for the pit's settings - #362

Merged
ralyodio merged 1 commit into
mainfrom
worktree-cloud-sync-settings
Aug 11, 2026
Merged

feat(account): /save and /load — cloud sync for the pit's settings#362
ralyodio merged 1 commit into
mainfrom
worktree-cloud-sync-settings

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Closes the gap where a new machine starts as a stranger: your aliases and herd rules live in ~/.moshcode on exactly one box.

/save (moshcode save) pushes them to the app.moshcode.sh account you already /login to. /load brings them down on any paired machine. Both take --dry-run, --force, --json.

mosh ▸ /save
  ✓ saved 2 files to you@example.com (revision 3)
     aliases.json  pit aliases
     herd/rules.json  herd state rules

mosh ▸ /load          # after editing aliases locally
  1 local file changed since this machine last synced:
     aliases.json
     `/save` to keep them, `/load --force` to replace them, `/load --dry-run` to see the difference

What syncs

aliases.json and herd/rules.json — an allowlist, never a directory walk, because the same directory holds credentials.json (the token this feature authenticates with), live herd state and the package cache. NEVER_SYNCED asserts that on top of the allowlist so a future entry can't leak the token, and every path is re-checked on the way in: a snapshot is data from the network, and an unchecked name in it would make /load a remote write primitive.

Nothing is overwritten quietly

  • Each save is a numbered revision; /save sends the one it last agreed on, so another machine's save is refused rather than erased. The compare-and-set is inside the INSERT (a HAVING on the same aggregate) — read-then-insert lets two requests against Turso both take the same revision.
  • /load refuses to replace a file edited since the last sync, and names it.
  • An identical snapshot is recognised by the account and answered with the revision it already holds: an unchanged /save costs no history, and a machine whose marker went stale (settings deleted from the web) finds out instead of insisting it is up to date.
  • The last ten revisions are at /settings/sync — promote one, or delete the lot. The page shows file names, never contents.

Notes for review

  • The digest is implemented on both sides; both suites pin the same hex for a fixed input, because the two disagreed once (NUL frame vs space) and nothing caught it — no code path compared them.
  • Migration 012_settings_sync.sql. The unique index on (user_id, revision) is load-bearing, not housekeeping.
  • 27 CLI tests + 13 app tests; app tests use the createRequire guard so they skip cleanly when apps/pwa/node_modules isn't installed. Full root suite: 1709 pass, 0 fail.
  • Verified end-to-end against a local app: two fake machines, one account — save, load, conflict, --force, forget-and-resave, and both web pages.

PRD: prd/0010-cloud-settings-sync.md

🤖 Generated with Claude Code

…code.sh account

A pit becomes yours by accretion: a dozen aliases, herd rules you tuned until the
roster stopped lying to you. All of it lived in ~/.moshcode on exactly one
machine, so every new laptop, container and droplet started as a stranger.

`/save` (moshcode save) pushes that configuration to the account you already
`/login` to; `/load` brings it down onto any machine paired with it. Both take
--dry-run, --force and --json.

What syncs is an allowlist — aliases.json and herd/rules.json — never a directory
walk, because ~/.moshcode also holds credentials.json, live herd state and a
package cache. NEVER_SYNCED asserts that on top of the allowlist so a future
addition can't leak the token, and every path is re-checked on the way *in*: a
snapshot is data from the network, and an unchecked name in it would make /load a
remote write primitive.

Nothing is overwritten quietly. Each save is a numbered revision and /save sends
the one it last agreed on, so a second machine's save is refused rather than
silently erased — the compare-and-set lives inside the INSERT, because two
requests against a network database both read the same MAX(revision). /load
refuses to replace a file edited since the last sync and names it.

The account recognises a byte-identical snapshot and answers with the revision it
already holds, so an unchanged /save costs no history and a machine whose marker
went stale (settings deleted from the web) finds out instead of insisting it is up
to date. The last ten revisions are listed at /settings/sync, where one can be
promoted or the lot deleted; the page shows file names, never contents.

PRD 0010. The digest is computed on both sides and both suites pin the same hex
for a fixed input — the two implementations disagreed once, and nothing caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
// account knows what it holds — a CLI that skipped the request on the strength
// of its own marker reported "already saved" to someone who had just deleted
// everything from the web, and left them stuck behind a --force.
if (current && current.digest === digest) {
Comment thread src/settings-sync.mjs
const { snapshot } = collectSnapshot({ home, installed: { engines: [], tools: [] } });
const digest = digestFiles(snapshot.files);
if (!marker?.digest) return { known: false, drifted: true, digest, files: Object.keys(snapshot.files).sort() };
if (marker.digest === digest) return { known: true, drifted: false, digest, files: [] };

const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-settings-sync-test-"));
process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
process.env.SESSION_SECRET = "test-secret";
fs.mkdirSync(moshcode, { recursive: true });
if (credentials) {
fs.writeFileSync(path.join(moshcode, "credentials.json"),
JSON.stringify({ token: "mck_super_secret", email: "a@b.c" }));
/** The per-file digest the marker stores — tests build markers that look real. */
const hash = (content) => crypto.createHash("sha256").update(content).digest("hex");

const CREDS = { api: "https://app.test", token: "mck_test", email: "a@b.c" };
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

94 finding(s)

HIGH/CRITICAL: 5 | MEDIUM: 38 | LOW: 51

Severity Rule Location
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH tls-verification-disabled apps/pwa/src/lib/moshpit-gateway.mjs:299
HIGH sh-remote-script-execution install.sh:79
HIGH sh-remote-script-execution install.sh:83
HIGH tls-verification-disabled src/dns.mjs:741
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:61
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:75
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:101
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:265
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:269
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:314
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:499
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:675
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:677
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:736
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:782
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:852
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:955
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1063
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1199
MEDIUM js-unescaped-html-sink apps/pwa/src/routes/moshpit.mjs:1419
MEDIUM js-timing-unsafe-mac-compare apps/pwa/src/routes/settings-sync.mjs:179
MEDIUM js-dynamic-code-execution apps/pwa/test/apikey-mask.test.mjs:129
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:111
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:131
MEDIUM sql-template-interpolation apps/pwa/test/moshpit-terms.test.mjs:192
MEDIUM sql-string-concatenation src/cli-schema.mjs:127
MEDIUM js-timing-unsafe-mac-compare src/settings-sync.mjs:309
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:93
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:310
MEDIUM insecure-temp-file test/plugins.test.mjs:152
MEDIUM insecure-temp-file test/pty.test.mjs:28
MEDIUM insecure-temp-file test/pty.test.mjs:31
MEDIUM insecure-temp-file test/pty.test.mjs:40
MEDIUM insecure-temp-file test/pty.test.mjs:42
MEDIUM insecure-temp-file test/pty.test.mjs:47
MEDIUM insecure-temp-file test/pty.test.mjs:48
MEDIUM insecure-temp-file test/pty.test.mjs:49
MEDIUM insecure-temp-file test/tabs.test.mjs:8
MEDIUM insecure-temp-file test/tabs.test.mjs:13
MEDIUM insecure-temp-file test/tabs.test.mjs:14
MEDIUM insecure-temp-file test/tabs.test.mjs:22
MEDIUM insecure-temp-file test/trust.test.mjs:240
LOW secret-generic-credential apps/pwa/test/apikey-bearer-scheme.test.mjs:30
LOW secret-generic-credential apps/pwa/test/apikey-mask.test.mjs:38
LOW secret-generic-credential apps/pwa/test/apikey-reveal.test.mjs:35
LOW secret-generic-credential apps/pwa/test/approvals-context.test.mjs:28
LOW secret-generic-credential apps/pwa/test/approvals-credits.test.mjs:28
LOW secret-generic-credential apps/pwa/test/approvals-notify.test.mjs:26
LOW secret-generic-credential apps/pwa/test/approvals-resolve-race.test.mjs:20

…and 44 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review August 11, 2026 09:12
@ralyodio
ralyodio merged commit b7c4976 into main Aug 11, 2026
4 checks passed
@ralyodio ralyodio mentioned this pull request Aug 11, 2026
@ralyodio
ralyodio deleted the worktree-cloud-sync-settings branch August 11, 2026 09:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants