cmux remote: workspace handoff design + validated snapshot/restore prototype - #10086
cmux remote: workspace handoff design + validated snapshot/restore prototype#10086austinywang wants to merge 3 commits into
Conversation
Design for moving a running agent workspace local->cloud and back: state model, offload/recall protocol, path strategy from prototype evidence, non-sus auth, single-writer rule, discovery ledger, failure modes, composition with hive #8000/M3a/M5 + remote tmux #8382 + surface resume #8441, explicit adopt/redo position on PR #8116, and phased milestones (v1 Linux Cloud VM). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stdlib-only snapshot/restore/verify tool preserving the exact staged/unstaged/untracked split, submodule pins, consent-gated ignored files, and flagged Claude/Codex session files, plus the manual resume-fidelity runbook (token-spending; not CI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ten stdlib unittest cases (fake agent homes, tempdirs, no network) covering split fidelity, staged deletions, submodule pinning, ignored-file consent, hidden-ref hygiene, and no-overwrite safety; wired as one workflow-guard-tests step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis change adds a workspace handoff design, a snapshot/restore/verify CLI, hermetic tests, a manual validation lab for Claude and Codex sessions, and a CI workflow step that runs the tests. ChangesWorkspace Handoff
Estimated code review effort: 4 (Complex) | ~60 minutes Mergeability Score: 🟠 High · up to The PR adds workspace snapshot/restore tooling that can currently write outside intended directories, expose credentials or session data, alter the source repository, report failed runs as successful, or strand a user’s session during restore. These issues should be fixed before merging because they create concrete security, correctness, and recovery risks in the delivered tooling. Sequence Diagram(s)sequenceDiagram
participant SourceRepository
participant HandoffCLI
participant BundleManifest
participant DestinationRepository
participant AgentSessionStores
SourceRepository->>HandoffCLI: snapshot workspace and selected sessions
HandoffCLI->>BundleManifest: write Git bundle and manifest
DestinationRepository->>HandoffCLI: restore bundle
HandoffCLI->>DestinationRepository: reconstruct repository state
HandoffCLI->>AgentSessionStores: restore Claude and Codex session files
HandoffCLI->>DestinationRepository: verify restored state
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 25✅ Passed checks (25 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 12
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@scripts/cmux-workspace-handoff-lab.sh`:
- Line 49: Update the result-reporting output in the lab script, including both
occurrences, to print the tagged localhost URL http://127.0.0.1:17320/<tag>
instead of the raw RUN_DIR temporary path. Ensure no /tmp/cmux-... path is
emitted by the build report.
- Around line 336-342: Replace the raw file-copying loop that appends logs to
FINDINGS with sanitized result-field reporting only. Update the findings
generation around the "$LOGS" iteration to omit command output, stderr, provider
JSON, identifiers, paths, flags, and upstream messages, while retaining complete
private transcripts exclusively in the protected run directory.
- Line 219: Update the Claude and Codex handoff validation checks around the
visible resume-status condition and corresponding checks to compute each
destination workspace using pwd -P, parse the reported CWD from the output
separately, and require an exact match before reporting PASS. Replace broad
absolute-path grep validation while preserving the existing codeword/status
checks.
- Around line 51-52: Update the lab’s failure tracking around fail() and the
final script exit so any snapshot, restore, verification, or resume failure
produces a nonzero status. Keep intentionally blocked agent legs nonfatal by
recording them with a separate status instead of invoking fail(), including the
additional fail() call sites noted in the comment.
- Around line 12-18: Update the run-directory setup around LAB_ROOT, RUN_ID, and
RUN_DIR to set umask 077 and create RUN_DIR with an exclusive mktemp -d call
under LAB_ROOT. Remove the predictable timestamp-based RUN_ID path construction,
while preserving the existing SRC, DST, OBSERVED_HOME, and LOGS subpaths derived
from RUN_DIR.
- Around line 291-322: Before the Codex move-aside operation, add a guarded
rollback handler and INT/TERM/HUP traps that restore CODEX_MOVED to CODEX_FILE
when needed. Mark the guard complete only after the real-home restore succeeds,
and disable or clear the traps afterward so successful replacements are not
overwritten; ensure rollback handles both restore failure and interrupted
execution.
In `@scripts/cmux-workspace-handoff.py`:
- Around line 500-510: Update untracked_hashes to include
stat.S_IMODE(candidate.stat().st_mode) alongside the content hash for regular
files, while preserving the existing symlink representation. Add a regression
test covering an untracked executable whose mode changes from 0755 to 0644 and
assert verification detects the mismatch.
- Around line 385-390: Update the JSONL rewrite handling around json.loads in
the rewrite flow to reject invalid lines instead of applying line.replace(old,
new). Raise or propagate a clear error that identifies the affected line, while
preserving rewrite_json_value behavior for valid JSON objects and values.
- Around line 68-70: Update the error handling around the command result in the
Git operation to stop including raw result.stderr or result.stdout in the die
message. Return a generic sanitized failure message that identifies the Git
operation without exposing upstream diagnostics or the computed detail value.
- Around line 405-430: Validate all manifest-controlled paths before copying
during restore: in the agent restore logic around the Claude and Codex branches,
ensure each bundle path resolves beneath bundle and each Codex relpath resolves
beneath the selected agent home, rejecting absolute paths and traversal outside
those roots. Also update the ignored-file restore logic at
scripts/cmux-workspace-handoff.py lines 479-486 to validate payload paths
beneath bundle / "ignored-files" and destination paths beneath dest; apply the
same containment check before filesystem access or copying at both sites.
- Around line 318-326: The handoff snapshot flow around commit_tree_with_message
and the temporary ref must avoid writing synthetic commits and trees into the
source repository. Create a temporary Git object directory, configure Git with
it as the writable object database and the source repository’s object database
only as an alternate, then use that environment for all three commit-tree calls
and bundle creation; retain temporary ref cleanup and remove the temporary
object directory afterward.
- Around line 185-197: Update the include-ignored handling around
relpath_checked and shutil.copy2 to reject symlink sources before copying,
including symlinks whose targets remain inside the workspace. Preserve the
existing validation and copy behavior for regular files, and report the input
path through the established die error path.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: bc72a38c-522d-4b2d-bbd2-841404b6487d
📒 Files selected for processing (5)
.github/workflows/ci.ymlplans/feat-cmux-remote/DESIGN.mdscripts/cmux-workspace-handoff-lab.shscripts/cmux-workspace-handoff.pytests/test_workspace_handoff.py
| LAB_ROOT="/tmp/cmux-remote-lab" | ||
| RUN_ID="$(date +%s)" | ||
| RUN_DIR="$LAB_ROOT/run-$RUN_ID" | ||
| SRC="$RUN_DIR/src" | ||
| DST="$RUN_DIR/dst" | ||
| OBSERVED_HOME="$RUN_DIR/home-observed" | ||
| LOGS="$RUN_DIR/logs" |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Create the run directory with an exclusive private temporary directory.
RUN_ID is predictable. mkdir -p accepts an attacker-created /tmp/cmux-remote-lab/run-<timestamp> directory. The script then writes transcripts there and moves a Codex rollout there at Line 295. A local user can read or redirect session data through that pre-created path.
Use umask 077 and mktemp -d for RUN_DIR. Do not construct RUN_DIR from a timestamp.
Proposed fix
-LAB_ROOT="/tmp/cmux-remote-lab"
-RUN_ID="$(date +%s)"
-RUN_DIR="$LAB_ROOT/run-$RUN_ID"
+umask 077
+RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-remote-lab.XXXXXX")"
+RUN_ID="${RUN_DIR##*.}"Also applies to: 45-47
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/cmux-workspace-handoff-lab.sh` around lines 12 - 18, Update the
run-directory setup around LAB_ROOT, RUN_ID, and RUN_DIR to set umask 077 and
create RUN_DIR with an exclusive mktemp -d call under LAB_ROOT. Remove the
predictable timestamp-based RUN_ID path construction, while preserving the
existing SRC, DST, OBSERVED_HOME, and LOGS subpaths derived from RUN_DIR.
| SRC_ABS="$(cd "$SRC" && pwd -P)" | ||
| exec 3>"$LOGS/runbook.transcript" | ||
| printf '%s\n' 'manual runbook — spends agent tokens; NOT run in CI' | tee /dev/fd/3 | ||
| printf 'run directory: %s\n' "$RUN_DIR" | tee /dev/fd/3 |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Report the lab result through the tagged localhost URL.
These lines print the raw temporary directory path. Report the result as http://127.0.0.1:17320/<tag> instead. Do not print the /tmp/cmux-remote-lab/... path.
As per coding guidelines, shell build reports must use the tagged localhost URL and must not output /tmp/cmux-<tag>/... paths.
Also applies to: 343-343
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/cmux-workspace-handoff-lab.sh` at line 49, Update the
result-reporting output in the lab script, including both occurrences, to print
the tagged localhost URL http://127.0.0.1:17320/<tag> instead of the raw RUN_DIR
temporary path. Ensure no /tmp/cmux-... path is emitted by the build report.
Source: Coding guidelines
| pass() { printf 'PASS: %s\n' "$1" | tee -a "$LOGS/assertions.log" /dev/fd/3; } | ||
| fail() { printf 'FAIL: %s\n' "$1" | tee -a "$LOGS/assertions.log" /dev/fd/3; } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Return a failing status when the lab records a failure.
fail() only writes a log entry. The script reaches the final printf and exits with status 0 after a failed snapshot, restore, verification, or resume check. This makes automated invocation and manual result collection treat a failed lab as successful.
Track failures and exit nonzero at the end. If blocked agent legs are intentionally nonfatal, record them with a separate status instead of calling fail().
Proposed fix
+FAILED=0
pass() { printf 'PASS: %s\n' "$1" | tee -a "$LOGS/assertions.log" /dev/fd/3; }
-fail() { printf 'FAIL: %s\n' "$1" | tee -a "$LOGS/assertions.log" /dev/fd/3; }
+fail() { FAILED=1; printf 'FAIL: %s\n' "$1" | tee -a "$LOGS/assertions.log" /dev/fd/3; }
...
if ((KEEP == 0)); then
printf 'Run retained by default for judge inspection; --keep is accepted for compatibility.\n' | tee /dev/fd/3
fi
+exit "$FAILED"Also applies to: 343-346
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/cmux-workspace-handoff-lab.sh` around lines 51 - 52, Update the lab’s
failure tracking around fail() and the final script exit so any snapshot,
restore, verification, or resume failure produces a nonzero status. Keep
intentionally blocked agent legs nonfatal by recording them with a separate
status instead of invoking fail(), including the additional fail() call sites
noted in the comment.
| CLAUDE_DEST_FILE="$HOME/.claude/projects/$CLAUDE_DEST_SLUG/$CLAUDE_SID.jsonl" | ||
| CLAUDE_SRC_SIZE="$(wc -c <"$CLAUDE_FILE")" | ||
| CLAUDE_DEST_SIZE=0; [[ -f "$CLAUDE_DEST_FILE" ]] && CLAUDE_DEST_SIZE="$(wc -c <"$CLAUDE_DEST_FILE")" | ||
| if ((CLAUDE_RESUME_STATUS == 0)) && grep -qi "$CLAUDE_WORD" "$LOGS/claude-resume.stdout"; then |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Validate the resumed working directory against the destination path.
The Claude check only verifies the codeword. The Codex checks accept any extracted absolute path through grep -q "cwd=/". A response that reports the source workspace, another file path, or an unrelated path can pass. This does not validate workspace handoff CWD fidelity.
Compute each destination with pwd -P. Parse the reported CWD separately. Compare it exactly to that destination before reporting PASS.
Also applies to: 282-282, 312-312
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/cmux-workspace-handoff-lab.sh` at line 219, Update the Claude and
Codex handoff validation checks around the visible resume-status condition and
corresponding checks to compute each destination workspace using pwd -P, parse
the reported CWD from the output separately, and require an exact match before
reporting PASS. Replace broad absolute-path grep validation while preserving the
existing codeword/status checks.
| mv "$RUN_DIR/codex-dst" "$RUN_DIR/codex-dst-arm-a" | ||
| CODEX_REL="${CODEX_FILE#$HOME/.codex/}" | ||
| CODEX_MOVED="$RUN_DIR/moved-aside/$CODEX_REL" | ||
| mkdir -p "$(dirname "$CODEX_MOVED")" | ||
| mv "$CODEX_FILE" "$CODEX_MOVED" | ||
| if [[ -e "$CODEX_FILE" ]]; then | ||
| fail 'codex Arm B move-aside did not remove original rollout' | ||
| printf '| codex Arm B | BLOCKED | original rollout still existed after move; no restore attempted |\n' >>"$FINDINGS" | ||
| else | ||
| pass "codex Arm B moved this run's original rollout aside first" | ||
| printf 'codex moved-aside original: %s\n' "$CODEX_MOVED" >>"$FINDINGS" | ||
| if run_logged codex-real-restore python3 "$TOOL" restore --bundle "$RUN_DIR/codex-bundle" --dest "$RUN_DIR/codex-dst" --codex-home "$HOME/.codex"; then | ||
| CODEX_B_BEFORE_SIZE="$(wc -c <"$CODEX_FILE")" | ||
| set +e | ||
| (cd "$RUN_DIR/codex-dst" && "${CODEX_RESUME_ENV[@]}" codex exec resume "$CODEX_SID" --json -c model_reasoning_effort=low \ | ||
| "What codeword did I give you, and what is the absolute path of your current working directory?") >"$LOGS/codex-arm-b.stdout" 2>"$LOGS/codex-arm-b.stderr" | ||
| CODEX_B_STATUS=$? | ||
| set -e | ||
| CODEX_B_AFTER_SIZE="$(wc -c <"$CODEX_FILE")" | ||
| codex_answer_summary "$LOGS/codex-arm-b.stdout" >"$LOGS/codex-arm-b.summary" | ||
| printf '\nCodex Arm B (rollout-only restore into real HOME): exit=%s; restored rollout=%s; bytes=%s→%s; which rollout grew=%s\n' "$CODEX_B_STATUS" "$CODEX_FILE" "$CODEX_B_BEFORE_SIZE" "$CODEX_B_AFTER_SIZE" "$([[ "$CODEX_B_AFTER_SIZE" -gt "$CODEX_B_BEFORE_SIZE" ]] && echo "$CODEX_FILE" || echo 'none observed')" >>"$FINDINGS" | ||
| if ((CODEX_B_STATUS == 0)) && grep -qi "amber-$RUN_ID" "$LOGS/codex-arm-b.summary" && grep -q "cwd=/" "$LOGS/codex-arm-b.summary"; then | ||
| pass 'codex Arm B rollout-only restore resumed successfully' | ||
| printf '| codex Arm B | PASS | rollout alone at dated relpath sufficed; codeword/cwd and growth recorded above |\n' >>"$FINDINGS" | ||
| else | ||
| fail 'codex Arm B resume' | ||
| printf '| codex Arm B | REAL-BLOCKED/FAIL | exit %s; exact output/error retained in logs/codex-arm-b.*; restored rollout left in place |\n' "$CODEX_B_STATUS" >>"$FINDINGS" | ||
| fi | ||
| else | ||
| fail 'codex Arm B restore into real HOME' | ||
| printf '| codex Arm B | BLOCKED | real-home restore failed after move-aside; see logs/codex-real-restore.* |\n' >>"$FINDINGS" | ||
| fi |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Restore the moved rollout when real-home restore does not complete.
Line 295 removes the only rollout from $HOME/.codex. If the restore at Line 302 fails, or the script receives INT, TERM, or HUP after the move, the user’s session remains under the temporary run directory. The runbook has no rollback path.
Install a guarded exit and signal trap before the move. Move CODEX_MOVED back to CODEX_FILE unless the replacement restore completes successfully.
🧰 Tools
🪛 ast-grep (0.45.1)
[warning] 303-303: set +e (or set +o errexit) disables the shell's errexit option, so the script keeps running after a command fails. This masks failures of security-critical operations (downloads, signature/checksum verification, permission changes, cleanup of secrets), letting the script proceed with a bad or insecure state. Leave errexit enabled (set -e / set -euo pipefail), or handle failures explicitly with if/|| and an explicit exit instead of globally turning off failure detection.
Context: set +e
Note: [CWE-754] Improper Check for Unusual or Exceptional Conditions.
(set-plus-e-error-masking-bash)
🪛 Shellcheck (0.11.0)
[info] 292-292: Expansions inside ${..} need to be quoted separately, otherwise they match as patterns.
(SC2295)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/cmux-workspace-handoff-lab.sh` around lines 291 - 322, Before the
Codex move-aside operation, add a guarded rollback handler and INT/TERM/HUP
traps that restore CODEX_MOVED to CODEX_FILE when needed. Mark the guard
complete only after the real-home restore succeeds, and disable or clear the
traps afterward so successful replacements are not overwritten; ensure rollback
handles both restore failure and interrupted execution.
| for value in values: | ||
| rel = relpath_checked(root, value) | ||
| source = root / rel | ||
| if not source.exists() or source.is_dir(): | ||
| die(f"--include-ignored requires an existing file: {value}") | ||
| ignored = run_git(root, "check-ignore", "-q", "--", str(rel), check=False) | ||
| if ignored.returncode != 0: | ||
| die(f"path is not ignored by Git: {value}") | ||
| destination = target_root / rel | ||
| destination.parent.mkdir(parents=True, exist_ok=True) | ||
| shutil.copy2(source, destination) | ||
| mode = stat.S_IMODE(source.stat().st_mode) | ||
| os.chmod(destination, mode) |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- target file structure ---'
ast-grep outline scripts/cmux-workspace-handoff.py
printf '%s\n' '--- target lines ---'
sed -n '150,220p' scripts/cmux-workspace-handoff.py
printf '%s\n' '--- path and copy helpers/usages ---'
rg -n -C 4 'relpath_checked|include-ignored|copy2|check-ignore|target_root|ignored' scripts/cmux-workspace-handoff.py
printf '%s\n' '--- relevant tests/docs ---'
rg -n -C 3 'include-ignored|symlink|workspace-handoff|bundle' . --glob '!node_modules' --glob '!build' --glob '!dist' | head -300Repository: manaflow-ai/cmux
Length of output: 36340
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- relpath_checked implementation ---'
sed -n '88,112p' scripts/cmux-workspace-handoff.py
printf '%s\n' '--- imports and repository status ---'
sed -n '1,35p' scripts/cmux-workspace-handoff.py
git status --short
printf '%s\n' '--- path validation callers ---'
rg -n -C 5 'relpath_checked\(' scripts/cmux-workspace-handoff.py
printf '%s\n' '--- standalone symlink/path-behavior probe ---'
python3 - <<'PY'
from pathlib import Path
from tempfile import TemporaryDirectory
import os
import shutil
with TemporaryDirectory() as td:
root = Path(td) / "workspace"
outside = Path(td) / "outside"
root.mkdir()
outside.write_text("PRIVATE\n", encoding="utf-8")
link = root / "ignored-link"
link.symlink_to(outside)
candidate = (root / Path("ignored-link")).resolve()
try:
relative = candidate.relative_to(root.resolve())
validation = f"accepted as {relative}"
except ValueError:
validation = "rejected as outside root"
copied = Path(td) / "copied"
shutil.copy2(root / "ignored-link", copied)
print(f"source_is_symlink={link.is_symlink()}")
print(f"source_exists={link.exists()}")
print(f"source_is_dir={link.is_dir()}")
print(f"resolved_candidate={candidate}")
print(f"relpath_checked_equivalent={validation}")
print(f"copy_follows_symlink={copied.read_text(encoding='utf-8') == 'PRIVATE\\n'}")
PYRepository: manaflow-ai/cmux
Length of output: 3231
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- standalone symlink/path-behavior probe ---'
python3 - <<'PY'
from pathlib import Path
from tempfile import TemporaryDirectory
import shutil
with TemporaryDirectory() as td:
root = Path(td) / "workspace"
outside = Path(td) / "outside"
root.mkdir()
outside.write_text("PRIVATE\n", encoding="utf-8")
link = root / "ignored-link"
link.symlink_to(outside)
resolved = (root / Path("ignored-link")).resolve()
try:
relative = resolved.relative_to(root)
validation = f"accepted as {relative}"
except ValueError:
validation = "rejected as outside root"
inside_target = root / "real-file"
inside_target.write_text("WORKSPACE\n", encoding="utf-8")
inside_link = root / "ignored-inside-link"
inside_link.symlink_to(inside_target)
copied = Path(td) / "copied"
shutil.copy2(inside_link, copied)
print(f"outside_link_is_symlink={link.is_symlink()}")
print(f"outside_link_resolves_to={resolved}")
print(f"relpath_checked_equivalent={validation}")
print(f"inside_link_is_symlink={inside_link.is_symlink()}")
print(f"copy_is_symlink={copied.is_symlink()}")
print(f"copy_contains_target_contents={copied.read_text(encoding='utf-8') == 'WORKSPACE\\n'}")
PY
printf '%s\n' '--- script documentation and tests ---'
rg -n -C 5 'cmux-workspace-handoff|include-ignored|ignored-files|symlink|symbolic link' \
scripts README.md .github --glob '!**/node_modules/**' --glob '!**/build/**' --glob '!**/dist/**' || true
printf '%s\n' '--- script-specific files ---'
git ls-files 'scripts/*handoff*' '*handoff*test*' '*workspace*handoff*'Repository: manaflow-ai/cmux
Length of output: 490
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
from tempfile import TemporaryDirectory
import shutil
with TemporaryDirectory() as td:
root = Path(td) / "workspace"
outside = Path(td) / "outside"
root.mkdir()
outside.write_text("PRIVATE\n", encoding="utf-8")
link = root / "ignored-link"
link.symlink_to(outside)
resolved = (root / Path("ignored-link")).resolve()
try:
relative = resolved.relative_to(root)
validation = f"accepted as {relative}"
except ValueError:
validation = "rejected as outside root"
inside_target = root / "real-file"
inside_target.write_text("WORKSPACE\n", encoding="utf-8")
inside_link = root / "ignored-inside-link"
inside_link.symlink_to(inside_target)
copied = Path(td) / "copied"
shutil.copy2(inside_link, copied)
expected = "WORKSPACE\n"
print(f"outside_link_is_symlink={link.is_symlink()}")
print(f"outside_link_resolves_to={resolved}")
print(f"relpath_checked_equivalent={validation}")
print(f"inside_link_is_symlink={inside_link.is_symlink()}")
print(f"copy_is_symlink={copied.is_symlink()}")
print(f"copy_contains_target_contents={copied.read_text(encoding='utf-8') == expected}")
PY
printf '%s\n' '--- script documentation and tests ---'
rg -n -C 5 'cmux-workspace-handoff|include-ignored|ignored-files|symlink|symbolic link' \
scripts README.md .github --glob '!**/node_modules/**' --glob '!**/build/**' --glob '!**/dist/**' || true
printf '%s\n' '--- script-specific files ---'
git ls-files 'scripts/*handoff*' '*handoff*test*' '*workspace*handoff*'Repository: manaflow-ai/cmux
Length of output: 36842
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- workspace handoff tests ---'
rg -n -C 8 'ignored|symlink|include_ignored|include-ignored|mode|regular' tests/test_workspace_handoff.py
printf '%s\n' '--- restore and verification sections ---'
sed -n '437,540p' scripts/cmux-workspace-handoff.py
printf '%s\n' '--- lab fixture setup around ignored file ---'
sed -n '1,125p' scripts/cmux-workspace-handoff-lab.shRepository: manaflow-ai/cmux
Length of output: 19562
Reject ignored symlinks until the bundle format supports them.
relpath_checked blocks symlinks that resolve outside the workspace. However, shutil.copy2() follows an ignored symlink that resolves inside the workspace and restores its target as a regular file. Reject symlinks or preserve them in the bundle format.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/cmux-workspace-handoff.py` around lines 185 - 197, Update the
include-ignored handling around relpath_checked and shutil.copy2 to reject
symlink sources before copying, including symlinks whose targets remain inside
the workspace. Preserve the existing validation and copy behavior for regular
files, and report the input path through the established die error path.
| staged_commit = commit_tree_with_message(root, staged_tree, head, "cmux handoff staged tree") | ||
| worktree_commit = commit_tree_with_message(root, worktree_tree, staged_commit, "cmux handoff worktree tree") | ||
| untracked_commit = commit_tree_with_message(root, untracked_tree, worktree_commit, "cmux handoff untracked tree") | ||
| ref = f"refs/cmux/handoff-tmp/{uuid.uuid4().hex}" | ||
| run_git(root, "update-ref", ref, untracked_commit) | ||
| try: | ||
| run_git(root, "bundle", "create", str(out / "repo.bundle"), ref) | ||
| finally: | ||
| run_git(root, "update-ref", "-d", ref, check=False) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Keep temporary Git objects out of the source repository.
git commit-tree writes the three synthetic commits and their trees into the source object database. Deleting ref leaves those objects unreachable, but it does not restore source-repository hygiene. Build the commits and bundle with a temporary object directory and the source object database configured only as an alternate.
This violates the stated invariant that snapshot construction does not mutate the source repository.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/cmux-workspace-handoff.py` around lines 318 - 326, The handoff
snapshot flow around commit_tree_with_message and the temporary ref must avoid
writing synthetic commits and trees into the source repository. Create a
temporary Git object directory, configure Git with it as the writable object
database and the source repository’s object database only as an alternate, then
use that environment for all three commit-tree calls and bundle creation; retain
temporary ref cleanup and remove the temporary object directory afterward.
| try: | ||
| value = json.loads(body) | ||
| except json.JSONDecodeError: | ||
| rewritten.append(line.replace(old, new)) | ||
| continue | ||
| rewritten.append(json.dumps(rewrite_json_value(value, old, new), separators=(",", ":")) + ending) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Fail when a JSONL line is invalid during rewrite mode.
The fallback rewrites every occurrence of old in an unparsable line. That can alter transcript text outside the documented REWRITE_KEYS set. Reject the restore with a line-specific error instead of modifying unstructured content.
🧰 Tools
🪛 ast-grep (0.45.1)
[info] 389-389: use jsonify instead of json.dumps for JSON output
Context: json.dumps(rewrite_json_value(value, old, new), separators=(",", ":"))
Note: [CWE-116] Improper Encoding or Escaping of Output.
(use-jsonify)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/cmux-workspace-handoff.py` around lines 385 - 390, Update the JSONL
rewrite handling around json.loads in the rewrite flow to reject invalid lines
instead of applying line.replace(old, new). Raise or propagate a clear error
that identifies the affected line, while preserving rewrite_json_value behavior
for valid JSON objects and values.
| bundle_paths = descriptor.get("bundle_paths", []) | ||
| if kind == "claude": | ||
| old_cwd = descriptor["original_cwd"] | ||
| target_slug = claude_slug(dest) | ||
| primary = bundle / bundle_paths[0] | ||
| target = home / "projects" / target_slug / f"{descriptor['session_id']}.jsonl" | ||
| todo_targets = [home / "todos" / (bundle / rel_bundle).name for rel_bundle in bundle_paths[1:]] | ||
| ensure_no_overwrite(target) | ||
| for todo_target in todo_targets: | ||
| ensure_no_overwrite(todo_target) | ||
| target.parent.mkdir(parents=True, exist_ok=True) | ||
| shutil.copy2(primary, target) | ||
| if args.claude_cwd_mode == "rewrite": | ||
| rewrite_jsonl(target, old_cwd, str(dest)) | ||
| placements.append(str(target)) | ||
| for rel_bundle, todo_target in zip(bundle_paths[1:], todo_targets): | ||
| source = bundle / rel_bundle | ||
| todo_target.parent.mkdir(parents=True, exist_ok=True) | ||
| shutil.copy2(source, todo_target) | ||
| placements.append(str(todo_target)) | ||
| elif kind == "codex": | ||
| for rel_bundle, rel_original in zip(bundle_paths, descriptor.get("relpaths", [])): | ||
| target = home / rel_original | ||
| ensure_no_overwrite(target) | ||
| target.parent.mkdir(parents=True, exist_ok=True) | ||
| shutil.copy2(bundle / rel_bundle, target) |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Validate every manifest-controlled filesystem path before restore. The bundle manifest is input to restore, but these paths can be absolute or traverse outside their intended roots.
scripts/cmux-workspace-handoff.py#L405-L430: validate agentbundle_pathsunderbundleand Codexrelpathsunder the selected agent home.scripts/cmux-workspace-handoff.py#L479-L486: validate ignored-file payload paths underbundle / "ignored-files"and restore targets underdest.
🧰 Tools
🪛 Ruff (0.16.1)
[warning] 420-420: zip() without an explicit strict= parameter
Add explicit value for parameter strict=
(B905)
[warning] 426-426: zip() without an explicit strict= parameter
Add explicit value for parameter strict=
(B905)
📍 Affects 1 file
scripts/cmux-workspace-handoff.py#L405-L430(this comment)scripts/cmux-workspace-handoff.py#L479-L486
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/cmux-workspace-handoff.py` around lines 405 - 430, Validate all
manifest-controlled paths before copying during restore: in the agent restore
logic around the Claude and Codex branches, ensure each bundle path resolves
beneath bundle and each Codex relpath resolves beneath the selected agent home,
rejecting absolute paths and traversal outside those roots. Also update the
ignored-file restore logic at scripts/cmux-workspace-handoff.py lines 479-486 to
validate payload paths beneath bundle / "ignored-files" and destination paths
beneath dest; apply the same containment check before filesystem access or
copying at both sites.
| def untracked_hashes(repo: pathlib.Path) -> dict[str, str]: | ||
| paths = run_git_bytes(repo, "ls-files", "--others", "--exclude-standard", "-z").split(b"\0") | ||
| result: dict[str, str] = {} | ||
| for raw in paths: | ||
| if raw: | ||
| path = raw.decode() | ||
| candidate = repo / path | ||
| if candidate.is_symlink(): | ||
| result[path] = "symlink:" + os.readlink(candidate) | ||
| else: | ||
| result[path] = run_git(repo, "hash-object", "--", path).stdout.strip() |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Compare untracked regular-file modes during verification.
hash-object compares only file content. If an untracked executable file changes from 0755 to 0644, verify reports success despite a restored-state mismatch. Include stat.S_IMODE(candidate.stat().st_mode) in the untracked value and add a regression test.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/cmux-workspace-handoff.py` around lines 500 - 510, Update
untracked_hashes to include stat.S_IMODE(candidate.stat().st_mode) alongside the
content hash for regular files, while preserving the existing symlink
representation. Add a regression test covering an untracked executable whose
mode changes from 0755 to 0644 and assert verification detects the mismatch.
What this is
Design + validated prototype for cmux remote: moving a running agent workspace from the local Mac to a cmux Cloud VM (so agents keep running with the laptop closed) and bringing it back later — same workspace tab, same agent conversation, same branch/diff state. Design-and-prototype PR only; no app/runtime behavior changes.
Deliverables
Design doc —
plans/feat-cmux-remote/DESIGN.md(19 sections): exact state model, offload/bring-back protocol with state machine, transfer transport (control-plane bundle v1, hidden-refrefs/cmux/handoff/<id>v2), path strategy decided from experiment evidence, non-sus auth model (device-code OAuth on the user's own VM; never copies local tokens), single-writer enforcement with divergence detection, account-levelworkspace_handoffsdiscovery ledger sketch, failure-mode table, phased milestones (v1 Linux Cloud VM → BYO VPS later per Feature: first-class BYO VPS backend with fully direct data path (no cloud proxy for PTY/browser/agent traffic) #8003).origin/feat-remote-runtime-persistent-sessions(PR feat: persist remote runtime session state #8116 / hive M3a hive M3a: persistent remote runtime — server-authoritative session state in cmuxd-remote with detach/reattach #8081): adopt the Go runtime-state store + Swift transport files (all 27 merge-conflict hunks are plumbing, zero in the new logic), redo the app-target integration and the whole-SessionWorkspaceSnapshotpayload contract (hardschema_version == 1equality gate is unversionable for handoff).cmux remote(alias ofremotes,CLI/cmux.swift:4488) andcmux vm handoff(info printer,CLI/cmux.swift:4442) are taken → verbs land in the existingcmux workspacefamily asoffload/recall, with Go-relay parity cost called out.cmux vm snapshot/fork/restore,surface resume(Add remote-aware resume bindings for SSH workspaces #8441 flavors, resume: hookless directory-scoped continue bindings for remote agents (#7989) #10049 fallback tier, Revert hookless agent forking #6434 no-process-sniffing constraint), render-suspend feat: lightweight "render-suspend" hibernation tier — free GPU/renderer, keep PTY + session alive #5497.Prototype —
scripts/cmux-workspace-handoff.py(stdlib-only):snapshotcaptures the exact staged/unstaged/untracked split (three trees built from tempGIT_INDEX_FILEcopies; source repo provably never mutated), submodule SHA pins, consent-gated ignored files, and explicitly flagged Claude/Codex session files into a portable bundle;restorereconstructs the split into a different directory (tracked-deletion pass ordered before untracked extraction,protocol.file.allow=alwayssubmodule pinning, no-overwrite session placement);verifycompares the two ends. Plusscripts/cmux-workspace-handoff-lab.sh, the manual token-spending resume-fidelity runbook (not CI).Resume-fidelity findings (empirical, the load-bearing unknown) — proven on scratch repos under
/tmp/cmux-remote-lab/with real authenticated CLIs:-(src.dot_under→...-src-dot-under;/tmp→/private/tmpfirst). Copying the session.jsonlinto the destination slug dir is sufficient:claude -p --resume <id>at the new path recalls the seeded codeword, reports the new cwd, and appends to the destination slug's transcript. No transcript cwd-field rewrite required for v1 (a--claude-cwd-mode rewritearm exists and also works).codex exec resume <id>works from a different cwd (arm A), and the rollout file alone at its dated relpath in a codex home is sufficient on a simulated new machine (arm B: original moved aside, bundle copy restored, resume succeeds and recalls the codeword with an untainted prompt).Tests —
tests/test_workspace_handoff.py: 10 hermetic cases (fake homes, tempdirs, no network, no real agent CLIs) covering the split-fidelity matrix incl. same-file staged≠unstaged,git rm --cachedstaged-deletion-with-file-on-disk, symlinks/exec bits, ignored-exclusion + consent allowlist, non-tip submodule pinning + dirty-submodule refusal, hidden-ref hygiene, no-overwrite safety, and dangling-symlink verify. Wired as oneworkflow-guard-testsstep. Agent-resume validation stays a scripted manual runbook because it spends tokens and needs authenticated CLIs — stated honestly in the doc rather than faked into CI.Verification
python3 tests/test_workspace_handoff.py— 10/10 (also under a hostileTMPDIRcontaining_/.).python3 -m py_compileboth new python files;bash -non the runbook.python3 scripts/check-test-determinism.py --strict— exactly the 3 pre-existing findings inPackages/iOS/CmuxAgentChatUI/.../ChatAttachmentStagingTaskOwnerTests.swift(untouched here); zero findings attributable to this PR.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
Introduces the design and a validated snapshot/restore prototype for
cmux remoteworkspace handoff, and adds hermetic tests to CI. No app/runtime behavior changes; the only behavior change is a new CI step running the handoff tests.Scope and review focus
plans/feat-cmux-remote/DESIGN.md: state model, offload/recall protocol, path strategy, device-code OAuth on user VM, single-writer enforcement, failure modes, and phased milestones; adopts the Go runtime-state store and Swift transport files from PR feat: persist remote runtime session state #8116, redoes the app-target integration and theSessionWorkspaceSnapshotpayload contract.scripts/cmux-workspace-handoff.py: snapshot/restore/verify preserving the exact staged/unstaged/untracked split, submodule pinning, consent-gated ignored files, and flagged Claude/Codex session files; source repo never mutates, hidden-ref hygiene, no-overwrite safety.scripts/cmux-workspace-handoff-lab.shmanual runbook (token-spending) with proven Claude Code and Codex resume behavior; not run in CI.tests/test_workspace_handoff.py: 10 hermetic cases covering split fidelity, staged deletions, symlinks/modes, ignored-file consent, submodule pins/dirty refusal, hidden-ref hygiene, branch/detached-HEAD fidelity, session placement/cwd rewrite, dangling symlink verify, and agent binary absence handling.offloadandrecallin thecmux workspacefamily; not implemented in the app.Rollout
python3 tests/test_workspace_handoff.py; no user-facing changes or migrations required.Written for commit 4e7d96c. Summary will update on new commits.
Summary by CodeRabbit
New Features
Documentation