Skip to content

cmux remote: workspace handoff design + validated snapshot/restore prototype - #10086

Open
austinywang wants to merge 3 commits into
mainfrom
feat-cmux-remote
Open

cmux remote: workspace handoff design + validated snapshot/restore prototype#10086
austinywang wants to merge 3 commits into
mainfrom
feat-cmux-remote

Conversation

@austinywang

@austinywang austinywang commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

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

  1. Design docplans/feat-cmux-remote/DESIGN.md (19 sections): exact state model, offload/bring-back protocol with state machine, transfer transport (control-plane bundle v1, hidden-ref refs/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-level workspace_handoffs discovery 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).

  2. Prototypescripts/cmux-workspace-handoff.py (stdlib-only): snapshot captures the exact staged/unstaged/untracked split (three trees built from temp GIT_INDEX_FILE copies; source repo provably never mutated), submodule SHA pins, consent-gated ignored files, and explicitly flagged Claude/Codex session files into a portable bundle; restore reconstructs the split into a different directory (tracked-deletion pass ordered before untracked extraction, protocol.file.allow=always submodule pinning, no-overwrite session placement); verify compares the two ends. Plus scripts/cmux-workspace-handoff-lab.sh, the manual token-spending resume-fidelity runbook (not CI).

  3. Resume-fidelity findings (empirical, the load-bearing unknown) — proven on scratch repos under /tmp/cmux-remote-lab/ with real authenticated CLIs:

    • Claude Code (2.1.229): sessions are keyed by a project-dir slug of the resolved cwd where every non-alphanumeric becomes - (src.dot_under...-src-dot-under; /tmp/private/tmp first). Copying the session .jsonl into 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 rewrite arm exists and also works).
    • Codex (0.147.0): 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).
  4. Teststests/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 --cached staged-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 one workflow-guard-tests step. 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 hostile TMPDIR containing _/.).
  • python3 -m py_compile both new python files; bash -n on the runbook.
  • python3 scripts/check-test-determinism.py --strict — exactly the 3 pre-existing findings in Packages/iOS/CmuxAgentChatUI/.../ChatAttachmentStagingTaskOwnerTests.swift (untouched here); zero findings attributable to this PR.
  • Full runbook re-run on the exact committed scripts reproduced the findings end-to-end (claude dotted-path leg + codex arms A/B).
  • No Swift files touched; budget TSVs untouched. Localization audit: N/A — contributor design doc + dev tooling only; no user-facing strings.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Introduces the design and a validated snapshot/restore prototype for cmux remote workspace 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

  • Design doc 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 the SessionWorkspaceSnapshot payload contract.
  • Prototype 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.
  • Resume-fidelity evidence: scripts/cmux-workspace-handoff-lab.sh manual runbook (token-spending) with proven Claude Code and Codex resume behavior; not run in CI.
  • Tests 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.
  • CLI naming settled in doc: verbs offload and recall in the cmux workspace family; not implemented in the app.

Rollout

  • CI: adds a step to run python3 tests/test_workspace_handoff.py; no user-facing changes or migrations required.

Written for commit 4e7d96c. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added workspace handoff tools to snapshot, restore, and verify Git repositories across environments.
    • Preserves branches, staged and unstaged changes, untracked files, approved ignored files, submodules, and repository metadata.
    • Supports optional Claude and Codex session transfer, including working-directory adjustments.
    • Provides human-readable status messages, structured JSON output, overwrite protection, and path-safety checks.
  • Documentation

    • Added comprehensive guidance covering workflows, limitations, recovery behavior, and prototype usage.

austinywang and others added 3 commits August 12, 2026 21:12
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>
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This 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.

Changes

Workspace Handoff

Layer / File(s) Summary
Handoff contracts and capture foundations
plans/feat-cmux-remote/DESIGN.md, scripts/cmux-workspace-handoff.py
Defines the bundle schema, state categories, restore flows, safety rules, and session-handling model. Adds Git, path, submodule, ignored-file, and agent-session capture foundations.
Workspace snapshot creation
scripts/cmux-workspace-handoff.py, tests/test_workspace_handoff.py
Creates Git bundle snapshots for staged, worktree, untracked, ignored, submodule, branch, origin, and optional session state. Tests round-trip fidelity, source hygiene, ignored files, submodules, symlinks, and manifest metadata.
Restore and verification flows
scripts/cmux-workspace-handoff.py, tests/test_workspace_handoff.py
Restores repository and session state with path and overwrite checks. Supports branch and detached-HEAD restoration, Claude cwd rewriting, and structured verification results.
Manual and CI validation
scripts/cmux-workspace-handoff-lab.sh, .github/workflows/ci.yml
Adds manual Git, Claude, and Codex handoff experiments with findings reports. Adds a CI step for the workspace handoff test module.

Estimated code review effort: 4 (Complex) | ~60 minutes

Mergeability Score: 🟠 High · up to 4e7d9

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
Loading

Possibly related PRs

  • manaflow-ai/cmux#10049: Both changes address remote Claude and Codex session handoff, but this change uses a standalone snapshot/restore CLI while that PR uses persistent-SSH resume bindings.

Suggested reviewers: lawrencecchen

🚥 Pre-merge checks | ✅ 25
✅ Passed checks (25 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the cmux remote workspace handoff design and snapshot/restore prototype.
Description check ✅ Passed The description clearly explains the scope, deliverables, testing, verification, and the absence of app/runtime changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed The complete diff contains no Swift files or Swift declarations; it adds only documentation, Python/Bash tooling, tests, and a CI step, so the Swift actor-isolation check is inapplicable.
Cmux Swift Blocking Runtime ✅ Passed The PR diff changes only Markdown, Python, shell, and CI YAML files; it adds no Swift production code or blocking/timing primitives.
Cmux Browser Automation Off-Main ✅ Passed The full PR changes only design, Python/Bash, tests, and CI; scoped Swift files are unchanged, and no browser socket automation command or worker-lane routing was added.
Cmux Expensive Synchronous Load ✅ Passed The PR diff contains only Markdown, Python, shell, and YAML files; it adds no production Swift or Swift interactive-path loader changes covered by this check.
Cmux Cache Substitution Correctness ✅ Passed The complete PR diff changes only YAML, Markdown, Bash, and Python files; it contains no production Swift, TypeScript, or JavaScript changes subject to this check.
Cmux No Hacky Sleeps ✅ Passed PR-added scripts and tests contain no fixed sleeps, timers, polling, or wall-clock waits; the only workflow sleeps are pre-existing, and workflow YAML is out of scope.
Cmux Algorithmic Complexity ✅ Passed The PR adds no production Swift/TypeScript/JavaScript or runtime path. The new Python CLI is explicitly a prototype, the shell lab is manual, and CI only invokes tests.
Cmux Swift Concurrency ✅ Passed The full PR diff changes only YAML, Markdown, Python, and Bash files; it contains no Swift paths or new Swift concurrency patterns.
Cmux Swift @Concurrent ✅ Passed The complete PR diff changes only YAML, Markdown, Bash, and Python files; it introduces no Swift code or Swift concurrency annotations/call sites.
Cmux Swift Package Boundaries ✅ Passed The PR diff contains only Markdown, Python, Bash, YAML, and test files; it adds no Swift or SwiftPM production changes, so the boundary check is inapplicable.
Cmux Swiftpm Lockfiles ✅ Passed PR diff adds one CI test step and new design/prototype/test files; it changes no Package.swift, Package.resolved, Xcode project, or .gitignore policy.
Cmux Swift Logging ✅ Passed The PR diff from main changes no Swift files, so it introduces no Swift logging behavior covered by the rule.
Cmux User-Facing Error Privacy ✅ Passed The diff adds only a design document, developer tooling, hermetic tests, and CI wiring; it states production implementation is not included, so no production user-facing error change is introduced.
Cmux Full Internationalization ✅ Passed The diff adds only a design document, developer scripts, tests, and a CI step; it changes no Swift, web, app catalog, or Info.plist surface, and explicitly excludes production implementation.
Cmux Swiftui State Layout ✅ Passed The PR diff adds only YAML, Markdown, Bash, and Python files; it contains no Swift or SwiftUI changes, so the state-layout rules do not apply.
Cmux Architecture Rethink ✅ Passed The PR diff from merge-base adds only Markdown, Python, shell, and YAML files. It introduces no Swift changes or listed Swift architectural symptom patches.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed The PR diff against origin/main changes only YAML, Markdown, Bash, and Python files; it contains no Swift window code, so the auxiliary-window shortcut rule is not applicable.
Cmux Source Artifacts ✅ Passed The complete PR range adds only a CI config, design doc, handoff scripts, and hermetic test; no artifact directories or generated files enter source control.
Cmux No Test Or Debug Seam In Production Source ✅ Passed The PR diff against origin/main changes five non-Swift files and no paths under production Sources/, so it introduces no Swift test or debug seam.
Cmux No Ambient Global State ✅ Passed The PR range changes only workflow, design, Bash, Python, and test files; it introduces no production Swift changes or ambient Swift global state.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-cmux-remote

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between eeff5ce and 4e7d96c.

📒 Files selected for processing (5)
  • .github/workflows/ci.yml
  • plans/feat-cmux-remote/DESIGN.md
  • scripts/cmux-workspace-handoff-lab.sh
  • scripts/cmux-workspace-handoff.py
  • tests/test_workspace_handoff.py

Comment on lines +12 to +18
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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

Comment on lines +51 to +52
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; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment on lines +291 to +322
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.

Comment on lines +185 to +197
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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 -300

Repository: 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'}")
PY

Repository: 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.sh

Repository: 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.

Comment on lines +318 to +326
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.

Comment on lines +385 to +390
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.

Comment on lines +405 to +430
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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 agent bundle_paths under bundle and Codex relpaths under the selected agent home.
  • scripts/cmux-workspace-handoff.py#L479-L486: validate ignored-file payload paths under bundle / "ignored-files" and restore targets under dest.
🧰 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.

Comment on lines +500 to +510
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant