Repository navigation
Cloud copy fallback: name every skip, skip heavy folders and secrets outside git, progress, resume, disk_full, refuse system folders - #30427
Conversation
…outside git, progress, resume, disk_full; start-chat copy resumes its folder and refuses system folders Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-3bab30503f0b9a7059e5
…0280) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-3bab30503f0b9a7059e5
…older is never pushed over Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-3bab30503f0b9a7059e5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-3bab30503f0b9a7059e5
…(merges cleanly with #30002) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-3bab30503f0b9a7059e5
There was a problem hiding this comment.
7 issues found across 7 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="cmux-tui/crates/cmux-tui/src/link/sync_cli/skips.rs">
<violation number="1" location="cmux-tui/crates/cmux-tui/src/link/sync_cli/skips.rs:95">
P3: `canonicalize` errors are not all dangling-link errors, so inaccessible symlinks are reported as “to nothing.” Distinguish `NotFound` from other failures or add an unknown/inaccessible symlink reason.</violation>
<violation number="2" location="cmux-tui/crates/cmux-tui/src/link/sync_cli/skips.rs:146">
P2: `secret()` does not match credential files such as `.netrc`, `.npmrc`, or `.pgpass`, which hold plaintext tokens or passwords and would be pushed outside git. Add them to the matched names if they are meant to be secrets, or state the narrower scope in the doc comment.</violation>
</file>
<file name="cmux-tui/crates/cmux-tui/src/link/sync_cli.rs">
<violation number="1" location="cmux-tui/crates/cmux-tui/src/link/sync_cli.rs:428">
P2: Push progress stops for the entire duration of each file because this callback runs only after `write` completes. Pass the progress reporter into the file-write callback and report during large-file transfers so a multi-gigabyte copy does not appear stalled.</violation>
</file>
<file name="cmux-tui/crates/cmux-tui/src/link/sync_cli/local.rs">
<violation number="1" location="cmux-tui/crates/cmux-tui/src/link/sync_cli/local.rs:25">
P2: The new skip summary still silently omits Git files rejected by `add_git`—including hooks, lock files, and non-allowlisted metadata. Name or count those exclusions too, or narrow the “every skip” guarantee.</violation>
<violation number="2" location="cmux-tui/crates/cmux-tui/src/link/sync_cli/local.rs:308">
P2: Submodules and nested repositories are directories, so this arm labels them "a socket, FIFO or device". Add a directory arm that counts them, or add a named reason for folders in skips.rs.</violation>
</file>
<file name="cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs">
<violation number="1" location="cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs:310">
P1: The broad-folder guard can be bypassed with `--local //` or `--local ////`: trimming all trailing slashes produces an empty path, while `sync_cli::push` later canonicalizes that input to `/`. Normalize an all-slash path to `/` before checking it.</violation>
<violation number="2" location="cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs:314">
P2: The system-folder check misses canonical aliases such as `/private/etc` and `/private/var`, allowing selected folders to bypass the refusal. Compare against canonicalized `TOO_BROAD` entries as well as their literal spellings.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
| /// folder, or the home folder `home` itself, as spelled or as it resolves | ||
| /// (`/etc` is `/private/etc` on a Mac). | ||
| pub(super) fn too_broad(local: &str, home: Option<&Path>) -> bool { | ||
| let spelled = PathBuf::from(if local.len() > 1 { local.trim_end_matches('/') } else { local }); |
There was a problem hiding this comment.
P1: The broad-folder guard can be bypassed with --local // or --local ////: trimming all trailing slashes produces an empty path, while sync_cli::push later canonicalizes that input to /. Normalize an all-slash path to / before checking it.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs, line 310:
<comment>The broad-folder guard can be bypassed with `--local //` or `--local ////`: trimming all trailing slashes produces an empty path, while `sync_cli::push` later canonicalizes that input to `/`. Normalize an all-slash path to `/` before checking it.</comment>
<file context>
@@ -278,7 +278,43 @@ fn push(socket: &Path, local: &Path, remote: &str) -> Result<(), (String, String
+/// folder, or the home folder `home` itself, as spelled or as it resolves
+/// (`/etc` is `/private/etc` on a Mac).
+pub(super) fn too_broad(local: &str, home: Option<&Path>) -> bool {
+ let spelled = PathBuf::from(if local.len() > 1 { local.trim_end_matches('/') } else { local });
+ let resolved = std::fs::canonicalize(&spelled).ok();
+ let home_resolved = home.and_then(|home| std::fs::canonicalize(home).ok());
</file context>
| let spelled = PathBuf::from(if local.len() > 1 { local.trim_end_matches('/') } else { local }); | |
| let trimmed = local.trim_end_matches('/'); | |
| let spelled = PathBuf::from(if trimmed.is_empty() { "/" } else { trimmed }); |
| return Some(Why::EnvFile); | ||
| } | ||
| let lower = name.to_ascii_lowercase(); | ||
| let key = ["id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"] |
There was a problem hiding this comment.
P2: secret() does not match credential files such as .netrc, .npmrc, or .pgpass, which hold plaintext tokens or passwords and would be pushed outside git. Add them to the matched names if they are meant to be secrets, or state the narrower scope in the doc comment.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/cmux-tui/src/link/sync_cli/skips.rs, line 146:
<comment>`secret()` does not match credential files such as `.netrc`, `.npmrc`, or `.pgpass`, which hold plaintext tokens or passwords and would be pushed outside git. Add them to the matched names if they are meant to be secrets, or state the narrower scope in the doc comment.</comment>
<file context>
@@ -0,0 +1,222 @@
+ return Some(Why::EnvFile);
+ }
+ let lower = name.to_ascii_lowercase();
+ let key = ["id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]
+ .iter()
+ .any(|prefix| lower.starts_with(prefix))
</file context>
| bytes_total: set.files.values().map(|file| size(file).unwrap_or(0)).sum(), | ||
| ..Progress::default() | ||
| }; | ||
| if let Some(report) = progress.as_mut() { |
There was a problem hiding this comment.
P2: Push progress stops for the entire duration of each file because this callback runs only after write completes. Pass the progress reporter into the file-write callback and report during large-file transfers so a multi-gigabyte copy does not appear stalled.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/cmux-tui/src/link/sync_cli.rs, line 428:
<comment>Push progress stops for the entire duration of each file because this callback runs only after `write` completes. Pass the progress reporter into the file-write callback and report during large-file transfers so a multi-gigabyte copy does not appear stalled.</comment>
<file context>
@@ -338,9 +414,20 @@ fn push_inner(
+ bytes_total: set.files.values().map(|file| size(file).unwrap_or(0)).sum(),
+ ..Progress::default()
+ };
+ if let Some(report) = progress.as_mut() {
+ report(done);
}
</file context>
| //! machine ([`skips::heavy`]) and secrets (`.env*`, key files: [`skips::secret`]). | ||
| //! Symlinks, special files and names the machine cannot hold (control | ||
| //! characters, over 255 bytes, not UTF-8) are skipped too. Every skip is | ||
| //! named with why ([`Skips`]), except the git path's untracked `.env*` and |
There was a problem hiding this comment.
P2: The new skip summary still silently omits Git files rejected by add_git—including hooks, lock files, and non-allowlisted metadata. Name or count those exclusions too, or narrow the “every skip” guarantee.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/cmux-tui/src/link/sync_cli/local.rs, line 25:
<comment>The new skip summary still silently omits Git files rejected by `add_git`—including hooks, lock files, and non-allowlisted metadata. Name or count those exclusions too, or narrow the “every skip” guarantee.</comment>
<file context>
@@ -17,9 +17,13 @@
+//! machine ([`skips::heavy`]) and secrets (`.env*`, key files: [`skips::secret`]).
+//! Symlinks, special files and names the machine cannot hold (control
+//! characters, over 255 bytes, not UTF-8) are skipped too. Every skip is
+//! named with why ([`Skips`]), except the git path's untracked `.env*` and
+//! non-UTF-8 paths, which are counted.
</file context>
| ); | ||
| } | ||
| Ok(_) => set.skipped += 1, | ||
| Ok(meta) => set.skips.note_entry(&relative, path, &meta), |
There was a problem hiding this comment.
P2: Submodules and nested repositories are directories, so this arm labels them "a socket, FIFO or device". Add a directory arm that counts them, or add a named reason for folders in skips.rs.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/cmux-tui/src/link/sync_cli/local.rs, line 308:
<comment>Submodules and nested repositories are directories, so this arm labels them "a socket, FIFO or device". Add a directory arm that counts them, or add a named reason for folders in skips.rs.</comment>
<file context>
@@ -284,7 +305,7 @@ fn add(set: &mut LocalSet, relative: String, path: &Path) -> anyhow::Result<()>
);
}
- Ok(_) => set.skipped += 1,
+ Ok(meta) => set.skips.note_entry(&relative, path, &meta),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error).with_context(|| format!("cannot read {}", path.display())),
</file context>
| Ok(meta) => set.skips.note_entry(&relative, path, &meta), | |
| Ok(meta) if meta.is_dir() => set.skipped += 1, | |
| Ok(meta) => set.skips.note_entry(&relative, path, &meta), |
| let resolved = std::fs::canonicalize(&spelled).ok(); | ||
| let home_resolved = home.and_then(|home| std::fs::canonicalize(home).ok()); | ||
| [Some(spelled), resolved].into_iter().flatten().any(|path| { | ||
| TOO_BROAD.iter().any(|broad| path == Path::new(broad)) |
There was a problem hiding this comment.
P2: The system-folder check misses canonical aliases such as /private/etc and /private/var, allowing selected folders to bypass the refusal. Compare against canonicalized TOO_BROAD entries as well as their literal spellings.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs, line 314:
<comment>The system-folder check misses canonical aliases such as `/private/etc` and `/private/var`, allowing selected folders to bypass the refusal. Compare against canonicalized `TOO_BROAD` entries as well as their literal spellings.</comment>
<file context>
@@ -278,7 +278,43 @@ fn push(socket: &Path, local: &Path, remote: &str) -> Result<(), (String, String
+ let resolved = std::fs::canonicalize(&spelled).ok();
+ let home_resolved = home.and_then(|home| std::fs::canonicalize(home).ok());
+ [Some(spelled), resolved].into_iter().flatten().any(|path| {
+ TOO_BROAD.iter().any(|broad| path == Path::new(broad))
+ || home.is_some_and(|home| path == home)
+ || home_resolved.as_deref().is_some_and(|home| path == home)
</file context>
| /// whose name the machine can hold: noted with why. | ||
| pub(super) fn note_entry(&mut self, relative: &str, path: &Path, meta: &std::fs::Metadata) { | ||
| let why = if meta.file_type().is_symlink() { | ||
| match std::fs::canonicalize(path) { |
There was a problem hiding this comment.
P3: canonicalize errors are not all dangling-link errors, so inaccessible symlinks are reported as “to nothing.” Distinguish NotFound from other failures or add an unknown/inaccessible symlink reason.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/cmux-tui/src/link/sync_cli/skips.rs, line 95:
<comment>`canonicalize` errors are not all dangling-link errors, so inaccessible symlinks are reported as “to nothing.” Distinguish `NotFound` from other failures or add an unknown/inaccessible symlink reason.</comment>
<file context>
@@ -0,0 +1,222 @@
+ /// whose name the machine can hold: noted with why.
+ pub(super) fn note_entry(&mut self, relative: &str, path: &Path, meta: &std::fs::Metadata) {
+ let why = if meta.file_type().is_symlink() {
+ match std::fs::canonicalize(path) {
+ Ok(target) if target.starts_with(&self.top) => Why::SymlinkInside,
+ Ok(_) => Why::SymlinkOutside,
</file context>
|
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)
Shipped in:
|
|
Review (codex, router-review): SHIP at 1541610 Reviewer: codex gpt-6.1-sol xhigh, fast tier. Follow-ups: 3. Each lands as an issue in this repo that links this receipt; the lane that opened the PR fixes it forward. SHIP at 1541610 Visual verdict: no images; Leo tries the build.
Follow-ups:
|
Leo, 2026-10-31, on the start screen's line "Your project folder is copied up.":
This hardens the streamed copy (
cmux link sync push,cmux link start-chat copy), per leo-workspacedesign/cloud.md, "Getting the project onto the machine", rules 4, 5 and 7. The copy stays the fallback for non-git folders, repos without a GitHub remote and machines withoutgit-seed-v1; the git seed path is being built separately (link/seed/).What changes
Heavy folders outside git (
sync_cli/skips.rsheavy). In a non-git folder, a chat's copy skips the dependency and build folders the project rebuilds on the machine. These are always skipped:node_modules,.venv,__pycache__,.next,.nuxt,DerivedData,.gradle,.tox,.mypy_cache,.pytest_cache,.cache,.turboand.parcel-cache. The ambiguous names are skipped only next to the file that makes them a build folder:target: besideCargo.tomlorpom.xml.build: besidepackage.json, a Gradle file,setup.pyorpyproject.toml.dist: besidepackage.json,setup.pyorpyproject.toml.Pods: beside aPodfile.venv: when it holds apyvenv.cfg.Inside git nothing changes, since ignored files are already skipped. A
cmux link accessshare (push_kept) also keeps every file as before (Rules::Share). A top-level.gitignoreis not read outside git (optional in the brief, left out to keep this conservative).Secrets outside git.
.env*,id_rsa*,id_ed25519*,id_ecdsa*,id_dsa*,*.pem,*.p12,*.pfxand*.keyare skipped by default and named.Named skips. The summary keeps
skipped(the total) and addsskipped_files(the first 200, sorted by path) andskipped_counts:{"ok":true,"written":N,"unchanged":N,"deleted":N,"skipped":N,"bytes":N, "skipped_files":[{"path":"node_modules/","kind":"heavy","why":"a heavy folder rebuilt on the machine"}, ...], "skipped_counts":{"heavy":N,"secret":N,"symlink":N,"special":N,"name":N,"reserved":N,"other":N}}Each
whyis one of these:/othercounts skips that are not named: in git, the untracked.env*files and non-UTF-8 paths, and a share's own refusals. Those git lines sit right next to #30000's change tolocal.rs, so naming them waits until #30000 lands (follow-up). Non-git names that are not UTF-8 used to be dropped without being counted; now they are named. Case collisions are left out because they are not a real failure on Linux.Progress.
push_innertakes an optional progress callback.cmux link sync pushandcmux link start-chat copyprint progress on stderr as JSON lines: the totals before anything is written, then at most one line every 250 ms, then the end. The stdout answer line keeps its shape.{"progress":{"files_done":N,"files_total":N,"bytes_done":N,"bytes_total":N}}files_doneandbytes_donecount files that were written or found unchanged.Resume.
start-chat copy. A retry of the same send on the same machine whose copy did not finish (cut off, answer lost, process gone) reuses that copy's folder from the ledger (Ledger::copy_place) instead of a new random one.Leaseon its folder while its push runs: an flock on<state>/cloud-start-chat/copies/<hash>.lock, which the system releases when the process ends or is killed. A retry that finds the folder held by a live copy takes a new folder.Disk full. The machine's
fs.no_space(ENOSPC/EDQUOT infs_ops/error.rs) answers"error_code":"disk_full","error":"The Cloud machine's disk is full", for bothsync pushandstart-chat copy. The App already shows theerrortext.Too-broad folders.
start-chat copyrefuses with"error_code":"folder_too_broad"these folders, compared exactly, both as spelled and as resolved:/and the home folder itself./System,/Library,/Applications,/Users,/usr,/bin,/sbin,/private,/etc,/var,/tmp,/optand/Volumes./homeand/root, for Linux.A project below one of them is still copied. Swift's
CloudMoveFolder.isTooBroadis unchanged.Swift progress: not in this PR.
ProcessRunnerwrites the CLI's stderr to a temp file and reads it only after the process exits. Showing "Copying 1,204 of 8,310 files (312 MB of 2.1 GB)" as the step text needs a streaming reader and step plumbing inCloudStartChat, which is more than a few lines, so it is a follow-up.Merges with
local.rs): kept away from its lines.git merge-treeagainst its head 253391a is clean.sync_cli.rs,pull_into):git merge-treeagainst its head is clean.Tests
Run on the fleet with
cmux-ci test:cargo test -p cmux-tui --test link_sync, thelink::start_chat_cli/link::sync_cli/link::access_clilib tests, andcargo clippy -p cmux-tui --all-targets -- -D warnings.tests/link_sync.rs:outside_git_heavy_folders_secrets_and_links_stay_and_are_named: covers heavy folders (and abuild/with no project file beside it, which is kept),.env*, keys, a symlink inside the folder, a symlink outside it, a FIFO, the reasons and the counts.a_push_reports_its_progress_against_the_totals: the first line holds the totals, the last line is at the totals, and the answer keeps its shape.a_retry_of_an_interrupted_push_writes_only_what_had_not_landed: the fake machine refuses writes after 700 of 1200. The retry finds 500 unchanged (the saved manifest) and writes 700.a_full_disk_on_the_machine_answers_disk_full.start_chat_cli/tests.rs:a_retry_of_a_copy_that_did_not_finish_resumes_its_folder: this replacesa_copy_whose_answer_was_lost_is_never_written_over, whose assertion flips by design.a_retry_resumes_its_folder_only_when_no_live_copy_holds_it.two_copies_of_one_send_at_once_never_share_a_folder: unchanged, still passes.a_copy_of_the_root_a_system_folder_or_home_is_refused.Unit tests are in
skips.rs.Changelog
Improved: copying a project to a Cloud machine skips dependency and build folders and secrets outside git, names every skipped file with the reason, reports progress, resumes an interrupted copy, and says plainly when the machine's disk is full.
Risk: normal. The changes are in the copy path only. A
cmux link accessshare keeps its file set.Agent session: agent session
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.Summary by cubic
Hardens the streamed copy (
cmux link sync push,cmux link start-chat copy) so skips are named, heavy folders and secrets stay home outside git, interrupted copies resume, and the machine's limits are reported plainly.Skips outside git
node_modules,.venv,targetbesideCargo.toml,buildbesidepackage.json, etc.) and secrets (.env*, SSH keys,*.pem,*.key) are now skipped and named with a plain reason.skipped_files(first 200, sorted by path) andskipped_counts; each file carriespath,kind, andwhy.cmux link accessshare keeps its file set.Copy resilience
sync pushandstart-chat copyprint JSON progress on stderr (totals first, then at most every 250 ms, then the end); the stdout answer keeps its shape.start-chat copyresumes its unfinished copy's folder, held by an flock lease so two live copies never share one."error_code":"disk_full"with "The Cloud machine's disk is full".start-chat copyrefuses the root, system top folders, and the home folder itself withfolder_too_broad; a project below one of them is still copied.Written for commit 1541610. Summary will update on new commits.