Skip to content

Offload: npx @simular-ai/box offload/back/rsh/login, /box:offload and /box:back - #8

Merged
chenchenSimular merged 10 commits into
mainfrom
chenchen/offload
Nov 4, 2026
Merged

chenchenSimular merged 10 commits into
mainfrom
chenchen/offload

Conversation

@chenchenSimular

@chenchenSimular chenchenSimular commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Closes #7.

What

This ports upstream cocoonbox's Offload (design §27) to our CLI and the Claude Code plugin. The box side already ships in our images: sbxdesk v7's agent offload code is identical to upstream. Phase 1 evidence is in #7.

CLI (@simular-ai/box 0.2.0)

  • login / logout: the relay's first-party cocoonbox-cli OAuth client.
    • PKCE, with a loopback listener on 127.0.0.1; state and iss are checked.
    • The CLI gets a device token, which shows under Keys → Devices and is revoked by logout.
    • The token is stored in ~/.simular-box/credentials.json (0600, in a 0700 directory) behind a TokenStore interface, so a keychain backend can replace the file.
    • BOX_TOKEN wins over the saved token. --token - reads a personal key from stdin or a hidden prompt.
    • A token is never accepted as an argument and never printed.
  • rsh: Session.Exec used as an ssh-like remote shell for rsync and git.
    • Stdin is pulled one chunk at a time, so backpressure reaches the box.
    • It forwards eof and INT/TERM; a second signal exits 255.
    • -t puts the local terminal in raw mode and forwards SIGWINCH and the local terminfo.
    • It wakes a sleeping box first.
  • offload, back, and offload handover-path|--handover-prompt|inbox|status|send|secrets: a faithful port of Offloader.swift, OffloadGit.swift, OffloadPreflight.swift, OffloadEnvironment.swift and the hand-over template. That covers:
    • three Execs per offload;
    • rsync of the work tree and .git without objects;
    • a tar stream carrying the hand-over, env.json, the bundle and opted-in secrets;
    • back as fast-forward, else a clean merge applied uncommitted, else a branch;
    • the same exit codes 0, 1, 3 and 4.
  • offload install-cli and offload sign-in: terminal versions of the app's "Install in box" and "Sign in in the box".
    • install-cli runs the vendor installer over Exec.
    • sign-in runs the vendor login on a PTY relayed to your terminal. The first https link on that vendor's own hosts opens once in your browser, and no other link from the box is opened. You paste the code into the terminal. The login stays in the box.
  • Opening links never involves a shell (cli/src/box/browser.js).
    • The opener gets the parsed, normalized URL.href as one argv element: rundll32 url.dll,FileProtocolHandler on Windows (never cmd /c start), open on macOS, xdg-open on Linux, or BROWSER run the same way.
    • A sign-in link is only RFC 3986 characters. | ^ " ' < > and spaces end it, and an OSC 8 target that contains them is skipped.
  • Raw terminals (rsh -t, offload sign-in):
    • ssh's ~. at the start of a line disconnects (exit 255); ~~ sends ~.
    • gRPC keepalive (30 s ping, 15 s timeout, only while a call is open) makes a half-open stream fail instead of trapping the terminal. The relay's load balancer terminates HTTP/2 and answers these pings, so they never reach the Go server's ping policy.
  • EPIPE on stdout or stderr (rsh box cat big | head) cancels the call and exits 255 quietly, as ssh does, instead of crashing with a stack trace.
  • Protos are vendored from simular-ai/box (cli/proto/README.md) and loaded with @grpc/grpc-js and @grpc/proto-loader. Nothing is generated.

Plugin (claude/ 0.2.0): the box-offload skill, /box:offload and /box:back. The skill runs npx -y @simular-ai/box@^0.2.0.

Prior art: what we adopted and avoided

The full table is in #7.

  • Adopted:
    • The vendor CLI signs in inside the box, and nothing is copied. Copied Claude creds 401 after a refresh (OAuth refresh token not used when credentials are copied to remote/headless machines anthropics/claude-code#21765, #50743), and server-held subscription tokens (terragon, vibekit) are against Anthropic's policy.
    • History travels as a bundle, so no pushed branch or GitHub is needed. Requiring one is the top friction of Codex cloud, Cursor and --cloud.
    • Back never touches uncommitted local work and falls back to a branch, close to mngr's --uncommitted-changes fail.
    • Commits come back, not a patch (unlike codex apply).
    • Status comes from hooks into an outbox, not from tmux scraping. claude-squad's pane scraping keeps breaking (#216, #189).
    • The trust dialog is preset (mngr #1041).
  • Avoided:
    • Live two-way sync (mngr pair clock-skew conflicts).
    • A transcript as the only hand-over (teleport loses content across compaction).

Not changed / limits

  • No backend change.
  • macOS and Linux only, because offload needs rsync. Windows gets a clear block.
  • Back needs git ≥ 2.38 (merge-tree --write-tree), as upstream does.
  • One Exec stream lasts at most 1 h, because of the GCLB timeoutSec: 3600.
  • The app-only Offloaded sidebar is out of scope. offload status, inbox and the printed rsh -t … tmux attach line cover it.

Testing

  • npm test: 54 tests pass. lint is clean. claude plugin validate passes for the plugin and the marketplace.
  • Browser opener tests:
    • Every platform's argv for https://claude.ai/x?a=1&calc|whoami^%PATH% is one element; win32 is rundll32, never cmd; shell: false.
    • A real opener script receives exactly one argument, and no & ; | ^ $() payload ran.
  • Sign-in link tests: & stays in the link (harmless without a shell), and | and ^ end it.
  • ~. / ~~ escape tests.
  • An e2e EPIPE case: rsh … 'yes | head -c 50M' | head -c 10 gives rc 255 and no stack trace.
  • Unit tests cover the hand-over template, names, the sign-in link allowlist (OSC 8, escapes, look-alike hosts, userinfo), the tar stream (checked with system tar, including the pax long path), token store modes, endpoint resolution, the rule that a token is never an argument, rsh host words, preflight pieces, and the OAuth loopback (PKCE verifier/challenge, wrong state, wrong iss).
  • End to end against a fake box (test/fakebox/): a local gRPC server with Boxes.List/Ensure and a real Session.Exec, plus a fake cocoonbox-agent offload that follows the contract. It runs the real CLI, real rsync and git over our rsh, and the real bundle. It shows:
    • byte-exact 300 KiB through cat, exit 42, stderr kept apart, 255 for a bad token, and the token never printed;
    • exit 4 on the .env warning, then --yes moves the work tree including uncommitted and untracked files (ignored files stay), and the bundle carries HEAD and S;
    • back fast-forwards the box's commit;
    • a local edit in the meantime gives an uncommitted merge;
    • a conflicting edit gives the branch cocoonbox/back-* with the work tree untouched;
    • a box-side block exits 3 and prints the install-cli / sign-in commands.
  • Against prod: login --token - with a bogus key reached Boxes.List on relay.simular.cloud over native gRPC and got UNAUTHENTICATED invalid bearer token.
  • Prod e2e (2026-11-03, free test account, box main, sbxdesk:v4, agent 0.1.0+4fe5895, running before and after). Run from this branch at 91ffc0e:
    • Passed:
      • login: the consent page showed "cocoonbox CLI on Chenchens-MacBook-Pro"; after Allow the CLI printed signed in. credentials.json is 0600 in a 0700 directory, and Keys → Devices listed "Chenchens-MacBook-Pro / Command line".
      • rsh: runs as user in /workspace; git rsync tmux mise cocoonbox-agent are present; cocoonbox-offload-inbox is active and the tmpfs is mounted. Startup took 0.7 s, exit 42 came through, and 1 MB of random bytes through cat was identical.
      • offload install-cli --kind claude installed Claude Code 2.1.317 into ~/.local, which bash -lc then finds.
      • offload sign-in --kind claude: the vendor login ran on a PTY in the box. The CLI opened only the claude.com authorize link, once, in the browser. It stopped at Claude's own login page (Google / Apple / email): the test account has no Claude account to use, so I went no further, as instructed. Then ~. disconnected (exit 255), and no claude auth process was left in the box.
      • offload --kind claude on a throwaway repo gave exit 3: block box_cli: claude is signed out in the box, with the install-cli / sign-in hint, and nothing changed.
      • Transport checked directly against the box: rsync over rsh pushed a repo in 1.4 s (openrsync on the Mac, rsync 3.2.7 in the box), and git fetch over rsh with GIT_SSH_VARIANT=simple brought back a commit made in the box. The scratch directory was removed afterwards.
      • offload status printed "no box CLI session"; back with no record exited 1 with "nothing of this repo is in a box".
      • logout revoked the device: the old token then got UNAUTHENTICATED invalid bearer token, and the device was gone from Keys → Devices.
  • Full round trip on prod (2026-11-03, same box, at ce9e2ec; you signed in to Claude in the box)
    • Sign-in in the box:
      • login again, then offload install-cli --kind claude (16 s).
      • offload sign-in --kind claude opened claude.com in a new tab of the e2e Chrome. You signed in, and the code was pasted into the CLI's terminal.
      • It printed Login successful and signed Claude Code in inside main. In the box, claude auth status showed loggedIn: true (authMethod claude.ai).
    • Offload of a throwaway repo, task "append a line to README.md and commit":
      • offloaded to main: Claude Code 2.1.317, signed in as … in 5.3 s total: preflight 0.2, begin 0.5, snapshot 0.2, rsync 2.1, send 1.9 s.
      • offload status showed working. inbox showed #1 progress 13 s later, then idle.
      • The box agent committed dabef43 readme: note the offload.
    • back:
      • The first back timed out after 10 min (exit 1, nothing changed). The inbox reported BACK as pasted, but the text never reached Claude Code: no UserPromptSubmit hook, nothing in the transcript, an empty prompt in the tmux pane, and the session stuck at working.
      • A second BACK was taken at once, and the box agent posted back.
      • back then took 2.9 s: fast-forward. Local main is at dabef43 with the README line, the tree is clean, and refs/cocoonbox/{offload,index,back,pre-back}/<id> and refs/cocoonbox/box/main are present.
      • The box session ended (stopped, tmux gone).
    • Fixed in 6276303: when a pasted BACK draws no hook for 60 s while the session says working, back sends BACK once more with --interrupt. A queued BACK is never re-sent. A unit test covers both cases. The root cause is in the box's inbox (deliver in agent/src/offload/inbox.rs does not confirm the CLI took the paste) and is a backend ask.
    • Noted: the box has no git identity, so the box agent committed with the account's email as author. It said so in its hand-over.
    • Cleanup:
      • In the box: claude auth logout; removed the Claude install, /workspace/offload-demo2 and the offload state.
      • Account: logout revoked the device; only the CI personal key remains under Keys.
      • The box was left running, as it was before.
    • Cleanup: I removed the Claude Code install (~/.local/{bin,share,state}/claude, ~/.cache/claude, ~/.claude*) and left the box running, as it was.
    • The only fix the run exposed is in ce9e2ec: the block hint now names the kind (--kind claude) instead of <K>.

Manual e2e (free test account, prod box)

cd cli && npm ci
alias sbx="node $PWD/bin/simular-box.js"
sbx login                                   # browser consent; then Keys → Devices shows "Command line"
sbx rsh --box main 'uname -a; id -un; pwd; which git rsync tmux mise; systemctl --user is-active cocoonbox-offload-inbox'
sbx offload install-cli --kind claude
sbx offload sign-in --kind claude           # browser opens claude.ai; paste the code into the terminal
mkdir /tmp/offload-demo && cd /tmp/offload-demo && git init -q && echo 'print(1)' > a.py && git add -A && git commit -qm init
sbx offload handover-path                   # write a hand-over there, e.g. "add a test that runs a.py; commit it"
sbx offload --kind claude                   # expect "offloaded to main: Claude Code …"
sbx offload status; sbx offload inbox --wait 60
sbx back                                    # expect fast-forward plus the box agent's hand-over
git log --oneline -3
sbx logout                                  # the device disappears from Keys → Devices

Review

  • I self-reviewed with simular-code-review. The fixes it led to are in the follow-up commits: block offload when rsync is missing, report a failed revoke on logout, and skip the POSIX mode checks on Windows.
  • /secure-coding:invariant-review found 2 low findings, both fixed here: the box token reached the preflight's child processes (now only rsync and git over rsh get it), and a file:../.. secret entry could leave the repo (now refused).
  • An independent review (request changes at 3459b2c) found three problems, all fixed in 91ffc0e: on Windows, links opened through cmd /c start, so a box could inject commands and login's & URL broke; raw-terminal sessions had no escape and no keepalive; and EPIPE crashed rsh.
  • No catalog invariant was violated. The loopback answer is bound by state, PKCE and iss; tokens stay out of argv and logs; and only vendor-host https links are opened.

🤖 Generated with Claude Code

chenchenSimular and others added 9 commits November 4, 2026 21:15
Port upstream cocoonbox's Offload (design §27) to the CLI and the Claude Code
plugin. The box side is already in our images.

- login/logout: the relay's first-party cocoonbox-cli OAuth client (PKCE,
  loopback on 127.0.0.1). It gives a device token, kept in
  ~/.simular-box/credentials.json (0600). BOX_TOKEN wins over it.
- rsh: Session.Exec as rsync's and git's remote shell.
- offload / back / inbox / status / send / secrets: a faithful port of the
  Mac packer (preflight, snapshot, rsync + bundle tar stream, merge back
  against S).
- offload install-cli / sign-in: the vendor installer and the vendor login
  run in the box, from a terminal-only machine.
- claude/: box-offload skill, /box:offload and /box:back.
- Tests: unit tests, plus offload/back end to end against a fake box over
  real gRPC, rsync and git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p POSIX mode checks on Windows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s outside the repo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hin a minute

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inals, quiet EPIPE

- openBrowser never goes through cmd. Windows uses rundll32
  url.dll,FileProtocolHandler, macOS open, Linux xdg-open, and BROWSER is
  run the same way. Each gets the parsed, normalized URL as one argv
  element. A box printing https://claude.ai/x?&calc can no longer run a
  command on a Windows machine, and login's authorize URL (with &) now
  opens on Windows.
- A sign-in link is only RFC 3986 characters, so | ^ quotes and spaces end
  it. An OSC 8 target containing them is skipped.
- rsh -t and offload sign-in: ssh's ~. escape at line start disconnects
  (exit 255); ~~ sends ~.
- gRPC keepalive (30 s, 15 s timeout, only while a call is open), so a
  half-open stream fails instead of holding the terminal.
- EPIPE on stdout or stderr cancels the call and exits 255 without a
  stack trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sign-in hint

Found in the prod e2e run: the hint printed --kind <K>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prod e2e: the first BACK was reported "pasted", but it never reached
Claude Code 2.1.317 in the box. There was no UserPromptSubmit hook and no
transcript entry, and the inbox showed the session "working" from then on.
So `back` waited out its full 10 minutes. A second BACK went through at
once.

When the first BACK was pasted (not queued behind a real turn), the
session is still "working" after 60 s, and no hook ran since, back now
sends BACK once more with --interrupt. A queued BACK is never re-sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mular.ai

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…opping

An unconfirmed delivery does not prove the box's Claude stopped, so the
message no longer claims it. The pasted-delivery retry is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…here HEAD went

back now polls the box CLI session until it is idle (or blocked) on two
polls in a row before it sends BACK. A BACK pasted while Claude Code is
ending a turn can vanish and come back as delivery_unconfirmed, which a
prod run hit. If the session stays busy past the 10-minute budget, back
fails before sending anything and points at back --interrupt, which
skips the wait as before.

The merged and fast-forward summaries now say when this branch moved to
the box's commit and list what is left uncommitted, instead of calling a
moved HEAD "applied uncommitted".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chenchenSimular
chenchenSimular merged commit a171f08 into main Nov 4, 2026
27 checks passed
@chenchenSimular
chenchenSimular deleted the chenchen/offload branch November 4, 2026 13:53
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.

Offload: move a coding task into your box and back (npx @simular-ai/box offload/back, /box:offload, /box:back)

1 participant