Repository navigation
cmux link sync: keep symlinks inside the folder as symlinks; skip and name the rest - #34362
Conversation
…s, both ways A push makes an inside symlink on the machine through the new fs-symlink-v1 ops (fs.readlink, fs.symlink: relative text that stays inside the daemon's root, never over anything); the record keeps its text in links. A pull brings a machine's symlink back as a symlink when its own text stays inside the folder, by the same three-way rule as files. One pointing outside, at nothing or into a repository folder is skipped and named with why, on push and on pull. A machine without the ops gets none, each named. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
|
cmux status (one comment, edited in place; each line is updated by the job that owns it) Review App of this head (app.zip, kept 1 day)
web and size checks: green Shipped in:
|
|
Owned by an agent session · session |
|
Found 2 test failures on Blacksmith runners: Failures
|
|
Review (codex gpt-6.1-sol xhigh, router-review): BLOCK at 7b1cf6b BLOCK at 7b1cf6b
|
… free of symlinks Review fixes: a symlink named like the sync record is never pushed; a symlink swapped for a file mid-pull goes back; a machine link is checked through every link on its way on both sides; `cmux link access` pulls make no symlinks; a link the push wrote relative remembers this Mac's text so a pull does not call it changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
|
Review (codex gpt-6-astra high, router-review): BLOCK at 6e736fb BLOCK at 6e736fb
Validation: source review and isolated filesystem/algorithm reproductions; Cargo was unavailable. |
…link, check targets in the daemon Second review round: a chain of machine links is looked up as this Mac's disk compares names; a push never writes a file through a link the machine retargeted; fs.symlink follows the new link's text through the links on its way and takes it away again when it leaves the roots or names nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
|
I’m adding a repository-sync barrier for Cloud agent startup and writes. Your symlink work overlaps the legacy The shared primitive is For the legacy copy path, the proposed extension is connection-owned Can you confirm whether you are already adding a transfer transaction, or whether I should stack this extension after your PR? |
… fence links over the hooks folder Third review round: every file a push writes checks the machine's entry first and leaves a symlink there alone; link chains are looked up with Unicode normalization and case folded as a Mac's disk does; a machine link at or above core.hooksPath never comes back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
|
Review (codex gpt-6-astra high, router-review): BLOCK at 9be686e BLOCK at 9be686e Visual verdict: no images; Leo tries the build
Follow-ups:
|
Fourth review round: a push checks each folder above a file it writes (once per folder) and leaves the file here, named, when one of them is a symlink on the machine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
|
Review (codex gpt-6-astra high, router-review): BLOCK at 7394586 BLOCK at 7394586
Rust tests could not run because |
|
Review (codex gpt-6-astra high, router-review): SHIP at 7394586 Visual verdict: no images; Leo tries the build Checked the fixes for the earlier BLOCK rounds: link chains on pull, the hooks-folder fence, writes through machine links (the file's own path and the folders above it), daemon target checks, and the manifest name. The router could not post this receipt itself because the GraphQL quota ran out, so the lane posted it. Follow-ups:
Fleet checks at this head: link_sync 24/24, |
…answer read without an index 6b9bafd (#34362) added &to[common..] in relative_from and answer["target"] in readlink; clippy::indexing_slicing counts both (cmux-tui +2 over 831 after the teleport fix in this branch). common is at most to.len(), so to.get(common..) gives the same slice; answer.get("target") gives None where answer["target"] gave Null, and both end in the same "fs.readlink answered no target" error. Sites from the panic step's clippy JSON: last green tip ab54fff (fleet step 294210d667444f201633a143) against feat 851f9ed plus this branch's fixes, with the acpmux and cmux-tui rows raised on a throwaway branch for the diagnosis only (fleet step 20552d31e2aa6f074ebbba6e).
…uggestion gate, and link sync index reads (cmux-automations 1, cmux-tui, acpmux) (#34735) * base red: crash ratchet: notification.clear reads notification_id with a typed error, not expect 7467cdd (#34498) added `value.as_str().expect("catalog resource-id validation")` for the new notification_id field of notification.clear, which took the cmux-tui-core expect row from 190 to 191 and turned the crash ratchet red on feat-cmux-next. A non-string notification_id now returns the same validation error that notification_workspace returns for workspace_id (#23530 did the same for that field). resource_router.rs: 7 expects at 7467cdd^, 8 at 7467cdd, 7 here. `python3 scripts/cmux-next/crash_ratchet.py`: red at 6767426 (expect 190 -> 191), ok here. The catalog validates the field as a resource id first, so no request reaches the old expect; no behavioral test can fail on the base, and the ratchet is the regression check. * base red: panic ratchet: cmux-automations import reads the ledger id with get, not v["automation"] The panic ratchet (scripts/cmux-next/rust_panic_ratchet.py) on feat-cmux-next reports "cmux-automations: clippy::indexing_slicing 1 > new crate (must be 0)". cmux-automations is a new crate in the ratchet, so it must have 0 sites. The site is Store::import in store.rs. It reads the import ledger entry with v["automation"] on a serde_json::Value. Commit dbb578b ("teleport between this Mac, cmux Cloud and ssh:HOST; MCP automation tools", pushed directly, with no PR) added it. The fix uses v.get("automation").and_then(Value::as_str). Behavior does not change: the Index on a Value gives Null when the key is missing or the value is not an object, and as_str on Null gives None. get gives None in the same cases. Thus the id is "" in the same cases as before. The baseline is not raised. No allow and no crash-allow is added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * base red: panic ratchet: cmux automation's Cloud edit and audit set params with JsonSet, not params["..."] = dbb578b (teleport between this Mac, cmux Cloud and ssh:HOST) added three IndexMut writes on a serde_json Value in cli/automation.rs (the Cloud `edit` name and paused fields, and the `audit` automation filter). clippy::indexing_slicing counts each one, which took cmux-tui from 831 to 834 and kept the panic ratchet red on feat-cmux-next. cmux_json::JsonSet sets the same keys on the same objects and never panics. Sites from the panic step's clippy JSON: the last green tip ab54fff (fleet step 294210d667444f201633a143) against this branch's parent with the cmux-tui row raised for the diagnosis only (fleet step 33bab4d69e741549bf0dac14): +3 in cmux-tui, all at cli/automation.rs 1338, 1341 and 1380. Behavior is the same: each params value is a JSON object built by json!({...}) just before. * base red: panic ratchet: acpmux task plan updates its task with get_mut, not self.tasks[at] f61b2d9 (#34567) indexes self.tasks[at] in TaskPlan's update, and clippy::indexing_slicing counts it (acpmux 689 > 686 with the next two commits). at comes from position() just before, so get_mut(at)? finds the same task and never returns None there. Sites from the panic step's clippy JSON: last green tip ab54fff (fleet step 294210d667444f201633a143) against feat 851f9ed plus this branch's fixes, with the acpmux and cmux-tui rows raised on a throwaway branch for the diagnosis only (fleet step 20552d31e2aa6f074ebbba6e). * base red: panic ratchet: acpmux turn_retrying sets turnId with JsonSet, not retry["turnId"] = 8f918fa (#34306) writes retry["turnId"] with IndexMut on a serde_json Value, which clippy::indexing_slicing counts. cmux_json::JsonSet::set writes the same key on an object (and on null, as IndexMut does) and never panics; a non-object retry is left as it is. Sites from the panic step's clippy JSON: last green tip ab54fff (fleet step 294210d667444f201633a143) against feat 851f9ed plus this branch's fixes, with the acpmux and cmux-tui rows raised on a throwaway branch for the diagnosis only (fleet step 20552d31e2aa6f074ebbba6e). * base red: panic ratchet: acpmux suggestion gate marks a withheld suggestion with JsonSet, not nested IndexMut a4f11cd (#34496) writes _meta.acpmux.suggestionWithheld through nested IndexMut, which clippy::indexing_slicing counts (3 new hits). The same writes now go through cmux_json::JsonSet and get_mut: a non-object _meta or _meta.acpmux still becomes {} first, and an update that is not an object (where IndexMut would panic) is left as it is. Sites from the panic step's clippy JSON: last green tip ab54fff (fleet step 294210d667444f201633a143) against feat 851f9ed plus this branch's fixes, with the acpmux and cmux-tui rows raised on a throwaway branch for the diagnosis only (fleet step 20552d31e2aa6f074ebbba6e). * base red: panic ratchet: cmux link sync's relative link and readlink answer read without an index 6b9bafd (#34362) added &to[common..] in relative_from and answer["target"] in readlink; clippy::indexing_slicing counts both (cmux-tui +2 over 831 after the teleport fix in this branch). common is at most to.len(), so to.get(common..) gives the same slice; answer.get("target") gives None where answer["target"] gave Null, and both end in the same "fs.readlink answered no target" error. Sites from the panic step's clippy JSON: last green tip ab54fff (fleet step 294210d667444f201633a143) against feat 851f9ed plus this branch's fixes, with the acpmux and cmux-tui rows raised on a throwaway branch for the diagnosis only (fleet step 20552d31e2aa6f074ebbba6e). --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…round git in the store test After the merge of feat-cmux-next: a store-refused file written over the link skips a machine symlink at its path as the link path does (#34362), and the store test's repo turns off auto gc and maintenance, which raced the push's read of .git. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
…34604) * cmux link sync: push through a per-user content-addressed blob store The Mac hashes each file (FastCDC chunks above 4 MiB), uploads only the blobs the user's sync store lacks, and the machine daemon fetches the plan and blobs itself with a read-only grant (new blob-fetch-v1 capability, blob.fetch.start/status), verifying every chunk and file digest and writing through the fs roots and PendingWrite staging. The direct link copy stays the fallback: no capability, no --store, a store failure, a refused file, or under 1 MiB to write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * blob fetch: a finished plan runs again; lint fixes A repeated plan reuses only a running job. A finished one runs again, since a file it wrote may have changed on the machine since, and the push means the plan's bytes (review at 7669a36). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * link sync store: keep the symlink skip on the link fallback; no background git in the store test After the merge of feat-cmux-next: a store-refused file written over the link skips a machine symlink at its path as the link path does (#34362), and the store test's repo turns off auto gc and maintenance, which raced the push's read of .git. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * blob fetch: run at most 4 store fetches at once A start past the cap is refused (blob.busy) and the Mac copies over the link instead, so a peer cannot pile up fetch threads and runtimes (review at 42a6b92, F1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * blob fetch: read at most a blob's size from the store The body is streamed and the fetch fails past the size the plan gives, with or without a Content-Length (review at 12fbda9, F1; #34790). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * blob fetch: never write through a symlink at the path A symlink at a planned path is named as failed (fs.symlink) and its target is untouched; the Mac then skips the file as the link copy does (review at 0780e86, F1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * link sync store: record each fetched file's revision from its write; no link fallback while the fetch runs Fixes #34788 and #34789. Fleet run 41798757437 (blob_fetch, sync_cli, link_sync, link_sync_store tests and clippy) passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * link sync store: fence off a running fetch before anything else writes A start, and the new blob.fetch.cancel, cancel every running job under an overlapping folder and answer once none of them can commit. Every push to a machine serving blob-fetch-v1 fences first, so a retry with a changed plan, a small push over the link, or a fallback after a lost start answer can no longer be overwritten by an older job. A lost start answer stops the job or fails the push as unsettled. Plans name their folder; files outside it fail the plan. Fleet run 41807575657: blob_fetch 10, fs_wire 20, link_sync_store 2, sync_cli 30 passed (clippy's one redundant clone fixed after). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * link sync store: blob-fetch-v2 carries cancel; cancelled workers keep their place under the cap Pre-release blob-fetch-v1 daemons have no blob.fetch.cancel, so the Mac now asks for blob-fetch-v2 and treats a v1 machine as serving no store (a plain link copy, no fence). The running cap counts live fetch threads, so a cancelled worker still waiting on the store holds its slot until it ends. Fleet run 41816753662: blob_fetch 10, fs_wire 20, capabilities 6, link_sync_store 3, sync_cli 30, link::tests 13 passed; clippy clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * link sync store: a superseded push stops; the cap is taken under the jobs lock A push whose fetch a newer push to the same folder cancelled now fails instead of copying its files over the link, which could overwrite the newer push. A start counts its worker under the jobs lock where it checked the cap. Fleet run 41823696076: blob_fetch 10, link_sync_store 4, sync_cli 30 passed; clippy clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * link sync: a newer push deletes what an earlier push's fetch left A cancelled or failed fetch's committed files were in no manifest, so a later push whose tree dropped them never deleted them. blob.fetch.cancel now names every kept job under the folder; the push reads what each wrote, counts those files as its own at that revision, and forgets the jobs once its manifest is saved (new blob.fetch.forget). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * link sync: take over a fetched file only while the machine still has it as fetched Review fixes: a recovered file counts as this push's only when the machine's current revision is one a fetch left (so a later link write or a machine edit is never deleted), with every candidate revision kept when two jobs wrote one path; a job records each file under the commit lock; cancel retires old jobs before naming the kept ones, and a job retired since is skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * link sync: fence when the machine's capabilities are unknown or a start names no job Review fixes: a push whose identify got no answer still sends the cancel (a refusal means no fetch is served there), and a blob.fetch.start answer with no job is stopped like a lost one before anything goes over the link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * link sync: name the landed callback's type for clippy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * link sync store: checked access instead of indexing in the chunker and plan The panic ratchet refuses new indexing_slicing hits in cmux-tui. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * blob fetch: write with no-follow resolution, so no symlink on the path redirects a write Review fix (Codex at 2c52d77): the fetch checked only the final path once, then PendingWrite::begin followed a symlinked parent folder or one put there before the commit. begin_no_follow refuses either (fs.symlink_in_path), as the link copy's no-follow writes do; the Mac sends a refused file over the link, which skips it the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa * blob fetch: page written entries with skip, not a slice index Keeps the panic ratchet at its baseline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
![Fix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/fix-with-codesmith-light-3x.png)
Leo, 2026-11-03: "yeah let's do alllll of those things! :DDD", on the folder sync improvements. This one is the symlinks piece:
What changes
cmux link sync push/pull(cmux-tui/crates/cmux-tui/src/link/sync_cli):Push. A symlink whose target resolves inside the folder now goes to the machine as a symlink. Its text is kept as written when it is relative and stays inside by its own words; an absolute target inside the folder is rewritten relative from the link's folder. The record (
cmux-sync.json) gainslinks: {path: {target}}, omitted when empty, so older records still read. A link this push made and that is gone here is deleted on the machine only while the machine still has it with the same text. A path where the machine holds something else is left alone and named.Pull. A machine symlink comes back as a symlink by the same three-way rule as files, with the link text as its content: changed on both sides is a conflict and nothing here changes. The machine is not trusted, so only a relative text that stays inside the folder by its own words comes back. Absolute, climbing (
../../..), and.git-reaching texts are skipped. Links are made and removed through the existing O_NOFOLLOW folder handles and roll back with the rest of the transaction (place.rs). The pull answer now also hasskipped_files/skipped_counts.Skipped and named on both sides: outside the folder, to nothing, into the repository folder, the machine too old, something else at the path.
Daemon (
cmux-tui-corefs_ops). A new capability,fs-symlink-v1, with two ops:fs.readlink {path}returns{target, revision}, and never follows the link.fs.symlink {path, target}makes a link, never over anything that is there. The target must be relative, and it must stay inside the root when counted from where the link's folder really is, with every link on the way followed.The link entry gate admits these two ops exactly. A machine without them refuses the op, and the client then skips every link with the reason "this Cloud machine is too old to make one" instead of failing the push.
A
cmux link accessshare (files only) still takes no symlinks.Risk
This touches sync state and user data, so it gets a different-model review. The new daemon ops are fenced the same way as the rest of fs-v1:
The resolver already refuses to follow any link out of the roots. On this Mac, nothing is ever written through a symlink.
Tests
tests/link_sync.rsan_inside_symlink_round_trips_and_an_outside_one_is_skipped_and_named: push, then a machine edit, a pull, a re-pull and a re-push.a_symlink_changed_on_both_sides_is_a_conflict_that_changes_nothing.outside_git_heavy_folders_secrets_and_links_stay_and_are_namedis updated: the inside link now arrives as a link.place.rs:symlinks_are_placed_and_put_back_and_a_changed_one_stays(roll back).links.rs: unit tests for text resolution and rewriting.fs_wire_tests.rs:a_symlink_is_made_inside_the_root_and_one_leaving_it_is_refused, plus the gate tests extended to the two new ops.Observed / Not run
cmux-ci test:cargo test -p cmux-tui --test link_sync, the link unit tests, thecmux-tui-corefs tests and clippy (-D warnings). Results are in the first comment.Changelog
Added: Folder sync keeps symlinks that point inside the folder, as symlinks, and lists the ones it skips with the reason.
Agent session: agent session
🤖 Generated with Claude Code
Open in [code]smith · Need help on this PR? Tag
@codesmith-botwith what you need.Summary by cubic
Keeps symlinks that stay inside a synced folder as symlinks in
cmux link sync pushandpull; symlinks pointing outside the folder, to nothing, or into a repository folder are skipped and named with the reason on both sides. The pull answer now also includesskipped_filesandskipped_counts.fs-symlink-v1, addsfs.readlinkandfs.symlink: descriptor-relative, create-only, never following the last component, and accepting only relative targets that stay inside the root once every link on their way is followed.cmux link accessshares still take no symlinks.Written for commit dab083f. Summary will update on new commits.