Skip to content

feat(agent-memory-sync): activate cross-machine memory sync — reachability precheck, shared-tree profiles, launchd templates#63

Merged
LanNguyenSi merged 5 commits into
masterfrom
task-2026-07-22/memory-sync-activation
Jul 22, 2026
Merged

feat(agent-memory-sync): activate cross-machine memory sync — reachability precheck, shared-tree profiles, launchd templates#63
LanNguyenSi merged 5 commits into
masterfrom
task-2026-07-22/memory-sync-activation

Conversation

@LanNguyenSi

Copy link
Copy Markdown
Owner

Implements agent-tasks a56701d7: agent-memory-sync goes live as the cross-machine memory sync — Mac mini is the single source of truth (bare repo ~/memory-sync/pandora-memory.git), fallback machines pull when the mini is reachable and push writes back debounced with an offline queue.

What

  • Reachability precheck (src/memory-sync/reachability.ts, TDD): fast bounded probe before pull/push/queue-replay; unreachable remote = clean no-op/queue with one clear note, exit 0. Config/env/CLI surface (reachabilityTimeoutMs, reachabilityCheckCommand).
  • Machine profiles (profiles/): macbook / mac-mini / linux.example — shared remote tree via repositorySubdir: "pandora", whole-rootDir syncPaths (. → memory), stateDir outside rootDir, explicit slug-mapping docs (/Users/lan vs /Users/lannguyensi).
  • launchd templates (watch + periodic sync tick) mirroring the systemd unit; docs/machine-setup.md covers bare-repo bootstrap, per-machine activation, third-machine (Linux) setup, restore/rollback.
  • Hardening from review: ssh-host option-injection guard (dash/whitespace hosts → unsupported; CVE-2017-1000117 class), hidden-file exclusion on both walk sides (.DS_Store/dotfiles never sync), docs corrected (watch is fail-loud by design; periodic sync is a required companion).

Review

Two independent adversarial reviewer passes (initial + delta), both accept_with_notes, no high/critical; all 3 medium findings fixed in-branch (docs watch-claim + propagation gap; dash-host injection; hidden-file sweep — each verified red-then-green). Suite 88/88, typecheck, build, coverage 92.4/73.9/92.7 vs gate 86/65/86 — re-run independently by both reviewers.

Live acceptance (all 6 task criteria, real memory dirs, after fresh backups)

Seed 236 files; E2E both directions incl. deletion propagation (deleted=3); queue replay (queued=…replayed 1 queued snapshot(s)); conflict → inline markers, restore to pre-conflict state; watch debounce pushed a real memory edit automatically; production .DS_Store probe stayed out of the shared tree. Both machines run watch + periodic sync via launchd now. Ops extras: ssh ControlMaster for mini (probe 4.5s → 0.8s), remote tree pruned to exact 236-file parity.

Run docs: .ai/runs/2026-07-22-memory-sync-activation/ (local). Follow-up (separate task): watch-mode precheck/queue, env-override parse warning.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RNmuQNroiGaagoeZ82wQg8

LanNguyenSi and others added 5 commits July 22, 2026 20:03
…ofiles, launchd template

Adds a fast, bounded reachability precheck ahead of pull/push/queue-replay so
an unreachable remote (ssh host down, VPN dropped) is a clean no-op — one
note, exit 0, no `git ls-remote` hang, queue left untouched — instead of
blocking or spamming git timeouts. Wires it into `run`'s performPull/
performPush (new reachabilityTimeoutMs/reachabilityCheckCommand config,
--reachability-timeout-ms flag); `watch`'s existing fail-loud-on-push-failure
contract is intentionally left unchanged.

Also lands the operator's Mac-mini-as-source-of-truth activation: committed
machine profiles (profiles/macbook.json, mac-mini.json, linux.example.json)
documenting the Claude Code memory-slug namespace divergence per machine, a
macOS launchd template mirroring the existing systemd watch unit, and
docs/machine-setup.md covering bare-repo bootstrap, per-machine activation,
a third Linux machine, and restore/rollback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNmuQNroiGaagoeZ82wQg8
…k and mini profile

Fixes an ssh option-injection hole in the reachability precheck: a remoteUrl
host beginning with '-' (e.g. "-oProxyCommand=id:repo.git") was classified
as an ssh target and its host string handed straight to `execFileSync("ssh",
[..., host, ...])`, letting ssh parse it as an option instead of a hostname
(CVE-2017-1000117-style). classifyRemote() now treats any dash-prefixed host
(scp-like or ssh://) as unsupported, so the precheck is skipped entirely and
no ssh process is ever spawned for such a remote; git's own transport
hardening handles it from there. Covered by new red-then-green unit tests.

Corrects docs/machine-setup.md and the watch launchd template, which
incorrectly implied the reachability precheck (or its ssh-alias check)
covers `watch` — it does not; `watch` stays fail-loud by design, only
`run`'s pull/push/sync go through the precheck. Also closes the real gap
behind that confusion: `watch` is edge-triggered and never pulls, so a
fallback machine that reconnects after being offline won't see changes made
elsewhere without a further local edit. Adds a periodic `run --mode sync`
companion as a required (not optional) part of the fallback recipe: a new
launchd StartInterval template (docs/launchd/com.agent-memory-sync.sync.
plist.template) for macOS, and a systemd timer + oneshot service pair
documented for Linux.

profiles/mac-mini.json now points remoteUrl at the bare repo's local
filesystem path instead of looping back through the `mini` ssh alias to
itself, with the ssh form kept as a documented alternative for an external
bare-repo host.

Strengthens the offline-push/pull integration tests to assert on the
specific reachability-probe reason text (not just the word "unreachable"),
so a revert of the precheck wiring can't coincidentally still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNmuQNroiGaagoeZ82wQg8
…he whole memory dir

A live cross-machine E2E test (real bare repo on the mac mini) caught the
committed profiles not actually syncing: profiles/macbook.json and
profiles/mac-mini.json each set repositorySubdir to a machine-specific value
(mirroring the profile name), so every machine wrote to its own top-level
tree in the bare repo and none of them ever saw each other's pushes (mini
`pull` after a MacBook `push` reported applied=0). Root-caused by reading
toRepositoryRelativePath/collectLocalSyncFiles/mapRemotePathToLocalAbsolute
in src/memory-sync/config.ts: repositorySubdir is the sole remote-tree
selector; the "profile" field only labels a machine's local stateDir and
has no effect on the remote — confirmed empirically before touching any
profile.

All three profiles (macbook.json, mac-mini.json, linux.example.json) now
set the same repositorySubdir ("pandora"); "profile" stays machine-specific
since it's local-only and safe to differ.

Separately, syncPaths was still the scaffold template (a literal MEMORY.md
file plus nonexistent daily/logs directories), missing the real memory
directory's ~236 flat .md files entirely. Replaced with a single
`{ source: ".", destination: "memory" }` directory entry covering the whole
rootDir recursively (verified empirically that destination must be a real
segment like "memory", not "."  — a bare "." destination round-trips
through push but mapRemotePathToLocalAbsolute's prefix match silently drops
every file on pull/restore). Because syncPaths now walks the entire
rootDir, stateDir was moved to an explicit absolute path outside rootDir in
every profile, so the tool's own git working copies/queue/base snapshots
are never swept up as if they were memory content.

Adds tests/integration/cross-machine-profiles.test.ts, a hermetic
regression pin loading the real committed profile files (overriding only
--root-dir/--state-dir/--remote) against a local bare repo: two independent
temp "machines" push/pull each other's new flat files, and the bare repo is
asserted to contain exactly one shared top-level tree. Confirmed both cases
red against the pre-fix profiles (wrong repositorySubdir, and the new file
missing from appliedFiles due to the narrow syncPaths) before applying the
fix.

Updated docs/machine-setup.md to explain what repositorySubdir vs. profile
actually control, the corrected restore --path example
(memory/MEMORY.md, not MEMORY.md), and how to spot a re-diverged
repositorySubdir by inspecting the bare repo directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNmuQNroiGaagoeZ82wQg8
The ssh handshake to the mini measures ~4.5s cold from this network — the
5000ms budget flapped at the edge (observed live: probe rc=0 at 4.49s,
next push timed out at 5000ms). Paired operationally with ssh
ControlMaster multiplexing for the mini host, which brings warm probes
to ~0.8s; the 10s budget covers the cold-start case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNmuQNroiGaagoeZ82wQg8
…ct profile docs

Includes an uncommitted docs/machine-setup.md edit already present in the
worktree (restore now documents that <sha> must be the full 40-char object
name, since git cannot fetch an abbreviated ref from a remote).

collectLocalSyncFiles/walkFiles applied no ignore filter: empirically
confirmed .DS_Store, nested .DS_Store, arbitrary dotfiles, and .git
directory contents all got swept into a directory-kind syncPaths' recursive
walk and pushed. On macOS this produces recurring inline-conflict-marker
diffs from per-machine-divergent .DS_Store binaries. Both walkFiles
implementations (src/memory-sync/config.ts for the local rootDir walk;
src/memory-sync/git-client.ts for reading the remote's current tree during
pull/push/watch) now skip any dot-prefixed entry and do not descend into
dot-prefixed directories — symmetric by construction, since the same
GitClient.listFiles() read is what pull uses to decide what to materialize
locally, so hidden junk already sitting in the remote is equally never
pulled down. Covered by a new tests/integration/hidden-files.test.ts,
confirmed red against the unfiltered walk before the fix.

isUnsafeSshHost only checked host.startsWith("-"), so a host with leading
whitespace before a dash-prefixed option (" -oProxyCommand=..." or a
leading tab) slipped past the guard and was still classified "ssh" — not
confirmed exploitable (ssh's own argv-option parser likely requires the
token itself, not a whitespace-padded string, to start with "-"), but
against the guard's stated intent. Replaced the startsWith check with a
single allowlist regex (alnum-first, then alnum/dot/hyphen/underscore) that
rejects leading dashes, leading/trailing whitespace, and any other
implausible character in one pass, without narrowing legitimate hostnames
(hyphenated, dotted, numeric). New red-then-green unit tests for the space
and tab forms, plus a companion test pinning that ordinary hostnames still
classify correctly.

Extended the repositorySubdir-equality regression test to also cover
profiles/linux.example.json (the copy-paste source for new machines), not
just macbook.json/mac-mini.json.

Corrected the profile-mechanism documentation (profiles/*.json "//" fields
and docs/machine-setup.md): "profile" only feeds the *default* stateDir
(.agent-memory-sync/<profile>) when stateDir is not set explicitly: every
committed profile now sets stateDir explicitly, so "profile" currently has
no file-path effect at all for these profiles — only a cosmetic label in
state.json. Also documents that walkFiles silently skips symlinks under
rootDir by construction (readdirSync's Dirent reports neither isFile() nor
isDirectory() for a symlink), which is why syncing one was never possible —
intentional containment, not a new behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNmuQNroiGaagoeZ82wQg8
@LanNguyenSi LanNguyenSi added review:tests-pass merge-approval gate prerequisite review:checklist-complete merge-approval gate prerequisite review:comments-resolved merge-approval gate prerequisite review:scope-matches-task merge-approval gate prerequisite review:evidence-logged merge-approval gate prerequisite labels Jul 22, 2026
@LanNguyenSi
LanNguyenSi merged commit 61d1457 into master Jul 22, 2026
3 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review:checklist-complete merge-approval gate prerequisite review:comments-resolved merge-approval gate prerequisite review:evidence-logged merge-approval gate prerequisite review:scope-matches-task merge-approval gate prerequisite review:tests-pass merge-approval gate prerequisite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant