cmux link sync: keep symlinks inside the folder as symlinks; skip and name the rest
Leo, 2026-11-03: "yeah let's do alllll of those things! :DDD", on the folder sync improvements. This one is the symlinks piece:
Keep symlinks whose target resolves inside the synced root, as symlinks. Skip ones that point outside it, and list them with the reason.
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:
- descriptor-relative, with no follow of the last component;
- create-only;
- relative targets only, with a lexical depth check against the walk's real depth.
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
- Ran on the fleet with
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. - Not run here: a DEV-build push/pull against a real Cloud machine. That end-to-end check is done after all three sync PRs land.
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
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Keeps symlinks that stay inside a synced folder as symlinks in cmux link sync push and pull; 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 includes skipped_files and skipped_counts.
- Push sends an inside symlink as a symlink, keeping relative text as written and rewriting an absolute inside target relative to the link's folder; a file is never written through a machine's symlink.
- Pull applies the same three-way rule as files, but only a relative text that stays inside the folder comes back, checked through every link on its way as this Mac's disk compares names; a symlink changed on both sides is a conflict that changes nothing, and one at or above the git hooks folder never comes back.
- A new daemon capability,
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. - Machines without the capability skip these symlinks with a named reason instead of failing the push.
- A symlink named like the sync record is never pushed, and one swapped for a file mid-pull goes back on rollback.
cmux link accessshares still take no symlinks.