Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ How it works:
- **Sign-in.** `login` signs the CLI in as the relay's first-party `cocoonbox-cli` client: the consent page opens in your browser, the answer comes back to a one-time listener on `127.0.0.1` (PKCE, `state` and issuer checked), and the CLI gets a device token of its own, listed under Keys -> Devices and revoked there or with `logout`. It is kept in `~/.simular-box/credentials.json` (`0600`, directory `0700`; `BOX_HOME` moves it). `BOX_TOKEN=cb_...` (a personal key) wins over it, for CI or a server. `login --token -` reads a personal key from stdin or a hidden prompt. A token is never accepted as an argument or printed.
- **The box CLI has its own login.** `offload sign-in` runs the vendor's login (`claude auth login`, `codex login --device-auth`, `cursor-agent login`) on a terminal in the box, relayed to yours, with the box's browser kept shut. The first https link it prints on that vendor's own sign-in hosts opens in your browser, once; no other link from the box is opened. Approve, then paste the code into your terminal. Nothing is copied from this computer.
- **Transport.** Everything goes over `Session.Exec` on the relay's gRPC: `npx @simular-ai/box rsh` is an ssh-like remote shell (`rsync -e`, `GIT_SSH_COMMAND`). An offload is three Execs: `begin` (box checks), rsync of the work tree and `.git` without objects, and `send`, whose stream carries the hand-over, `env.json`, the history as a git bundle and any opted-in secrets.
- **Back.** It waits for the box agent to go idle (a hand-over request pasted while it is ending a turn can be lost; `back --interrupt` stops its current step instead), then up to 10 minutes in all for its hand-over, fetches the box's commits over `rsh`, and lands them against the snapshot taken at offload: a fast-forward when your repo has not changed, else a clean merge applied uncommitted (never touching a file you changed meanwhile), else the branch `cocoonbox/back-<time>` with your work tree untouched.
- **Back.** It waits for the box agent to go idle (a hand-over request pasted while it is ending a turn can be lost; `back --interrupt` stops its current step instead), then up to 10 minutes in all for its hand-over, fetches the box's commits over `rsh`, and lands them against the snapshot taken at offload: a fast-forward when your repo has not changed, else a clean merge applied uncommitted (never touching a file you changed meanwhile), else the branch `box/back-<time>` with your work tree untouched.
- **Preflight.** It blocks on a non-git directory, a box missing `git`/`rsync`/`tmux`/`mise`, a missing or signed-out box CLI, an offload not yet brought back, and macOS-only commands. It warns (go on with `--yes`) about local services and VPN-only hosts the box cannot reach, git-ignored `.env*` files and other secrets (opt one in with `offload secrets add file:.env`; values travel only in the Exec stream and land on a tmpfs in the box), LFS and submodules, and a copy over 500 MB.
- **Limits.** macOS and Linux (needs `git` and `rsync`); git repositories only; the box agent commits but never pushes; one session per repo and three per box; the box agent runs with permission prompts bypassed.

Expand Down
4 changes: 2 additions & 2 deletions cli/src/offload/git.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export class GitRepo {
const patch = this.#diff(from, to)
if (patch.length && this.run(['apply', '--binary', '--whitespace=nowarn', '-'], { input: patch }).status !== 0) return false
if (move && lock) {
if (move.old) this.git(['update-ref', `refs/cocoonbox/pre-back/${id}`, move.old])
if (move.old) this.git(['update-ref', `refs/box/pre-back/${id}`, move.old])
const sym = this.run(['symbolic-ref', '-q', 'HEAD'])
const ref = sym.status === 0 && sym.text ? [sym.text] : ['--no-deref', 'HEAD']
this.git(['update-ref', '-m', 'box back', ...ref, move.new, ...(move.old ? [move.old] : [])])
Expand All @@ -242,7 +242,7 @@ export class GitRepo {

#branch(box, conflicts, now) {
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
const name = `cocoonbox/back-${stamp}`
const name = `box/back-${stamp}`
this.git(['branch', '-f', name, box])
return { kind: 'branch', branch: name, conflicts }
}
Expand Down
30 changes: 24 additions & 6 deletions cli/src/offload/offloader.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export const BACK_RETRY_S = 60
// How often back polls the box CLI's status while it waits for it to go idle before asking for the hand-over.
export const IDLE_POLL_MS = 2000

// Box images before simular-ai/box#78 only know the pre-rename refs: they check the snapshot under
// refs/cocoonbox/offload/<id> and write the back commit under refs/cocoonbox/back/<id>. Boxes keep their image until the
// user upgrades it, so offload also sends the snapshot under the old name (dropped here once sent) and back falls back to
// the old back ref. Newer images take refs/box/ first and ignore the rest.
const legacyRef = (kind, id) => `refs/cocoonbox/${kind}/${id}`

export class Offloader {
/**
* rsh is the remote-shell command rsync and git run (this CLI's `rsh --box <id>`); its children get the endpoint and
Expand Down Expand Up @@ -57,6 +63,7 @@ export class Offloader {
const excludes = path.join(stage, 'excludes')
const bundle = path.join(stage, 'offload.bundle')
const refs = []
let legacy = null
try {
if (runSync(['rsync', '--version'], { env: this.env }).status !== 0) {
findings.push(finding('rsync', 'block', 'rsync is not installed on this computer; offload copies the repo with it (macOS ships it; on Linux install the rsync package)'))
Expand Down Expand Up @@ -90,7 +97,7 @@ export class Offloader {
if (!dir) throw new OffloadError('failed', 'begin named no directory')
if (Array.isArray(begin.ignored) && begin.ignored.length) fs.appendFileSync(excludes, begin.ignored.map((p) => '/' + escape(p) + '\n').join(''))

refs.push(`refs/cocoonbox/offload/${id}`, `refs/cocoonbox/index/${id}`)
refs.push(`refs/box/offload/${id}`, `refs/box/index/${id}`)
const record = {
id, repo: key, box: this.box.id, box_name: this.box.name, name, dir, kind, base: null, top: repo.top,
started_at_ms: Date.now(), seen: 0,
Expand All @@ -104,6 +111,8 @@ export class Offloader {
const snapshot = repo.snapshot(index, `box offload ${id}`)
record.base = snapshot
repo.git(['update-ref', refs[0], snapshot])
legacy = legacyRef('offload', id)
repo.git(['update-ref', legacy, snapshot])
repo.keepIndex(refs[1], index)
const env = envJson(repo.top, this.env)
fs.writeFileSync(path.join(stage, 'env.json'), env)
Expand Down Expand Up @@ -140,6 +149,7 @@ export class Offloader {
const account = cli.account ? `, signed in as ${cli.account}` : ''
return { record, accepted: Boolean(reply.accepted), findings, cli: `${kindOf(kind).title} ${cli.version || ''}`.trim() + account, timings, sent }
} finally {
if (legacy) repo.run(['update-ref', '-d', legacy])
fs.rmSync(excludes, { force: true })
fs.rmSync(bundle, { force: true })
}
Expand Down Expand Up @@ -279,11 +289,19 @@ export class Offloader {
if (record.base && record.top) {
const repo = GitRepo.open(record.top, this.env)
if (!repo) throw new OffloadError('failed', `${record.top} is no longer a git repository`)
const ref = `refs/cocoonbox/back/${record.id}`
const fetch = await repo.runAsync(['fetch', '-q', `box:${record.dir}`, '+refs/heads/*:refs/cocoonbox/box/*', `+${ref}:${ref}`], {
extra: { GIT_SSH_COMMAND: this.rsh, GIT_SSH_VARIANT: 'simple', BOX_ENDPOINT: this.rshEnv.BOX_ENDPOINT, BOX_TOKEN: this.rshEnv.BOX_TOKEN },
})
if (fetch.status !== 0) throw new OffloadError('failed', `git fetch from the box failed (${fetch.status}):\n${fetch.error}`)
const ref = `refs/box/back/${record.id}`
const fetchBack = (from) =>
repo.runAsync(['fetch', '-q', `box:${record.dir}`, '+refs/heads/*:refs/box/box/*', `+${from}:${ref}`], {
extra: { GIT_SSH_COMMAND: this.rsh, GIT_SSH_VARIANT: 'simple', BOX_ENDPOINT: this.rshEnv.BOX_ENDPOINT, BOX_TOKEN: this.rshEnv.BOX_TOKEN },
})
const fetch = await fetchBack(ref)
if (fetch.status !== 0) {
// an older box image wrote the back commit only under its pre-rename name
const legacy = await fetchBack(legacyRef('back', record.id))
if (legacy.status !== 0) {
throw new OffloadError('failed', `git fetch from the box failed (${fetch.status}):\n${fetch.error}\nand under the older back ref (${legacy.status}):\n${legacy.error}`)
}
}
landing = repo.land({ id: record.id, base: record.base, box: ref, index: path.join(this.store.directory(key), 'snapshot.index') })
}
await this.verb(['end', '--repo', key])
Expand Down
7 changes: 6 additions & 1 deletion cli/test/fakebox/bin/cocoonbox-agent
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// A fake box packer for tests: the `cocoonbox-agent offload` verbs as contracts.md "Offload" states them, with the box
// copy under $FAKE_BOX_WORKSPACE and the inbox and outbox as files under $FAKE_BOX_STATE. It does what the CLI depends
// on (begin's refs, send's tar stream with the bundle indexed by `git index-pack --fix-thin`, read, end) and no CLI
// session: a test plays the box agent by committing in the copy and posting `back` with FAKE_POST.
// session: a test plays the box agent by committing in the copy and posting `back` with FAKE_POST. FAKE_BOX_LEGACY plays
// an image before simular-ai/box#78, which checks the snapshot only under refs/cocoonbox/offload/<id>.
import { execFileSync, spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
Expand Down Expand Up @@ -104,6 +105,10 @@ switch (verb) {
if (ref && ref.startsWith('refs/')) execFileSync('git', ['-C', dir, 'update-ref', ref, oid])
}
}
const snapshot = `refs/${process.env.FAKE_BOX_LEGACY ? 'cocoonbox' : 'box'}/offload/${opt('--id')}`
if (spawnSync('git', ['-C', dir, 'rev-parse', '-q', '--verify', `${snapshot}^{tree}`]).status !== 0) {
reply({ ok: false, code: 'failed', error: `${snapshot} is missing from the copy` }, 1)
}
execFileSync('git', ['-C', dir, 'update-index', '-q', '--refresh'], { stdio: 'ignore' })
save('session', { ...session, started: true })
reply({ ok: true, delivery: 'started', accepted: true, tmux: `cb-off-${repo}-${opt('--kind')}`, timings_ms: {} })
Expand Down
35 changes: 30 additions & 5 deletions cli/test/offload-e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,17 @@ function handover(goal) {
return HANDOVER_TEMPLATE.replace('<one-line goal>', goal)
}

/** playBoxAgent commits change in the copy, makes S' as the box packer's `post --back` does, and posts back. */
function playBoxAgent({ dir, offload_id: id, repo: key }, change) {
/**
* playBoxAgent commits change in the copy, makes S' as the box packer's `post --back` does, and posts back; legacy
* names S' as an image before simular-ai/box#78 does.
*/
function playBoxAgent({ dir, offload_id: id, repo: key }, change, { legacy = false } = {}) {
for (const [file, text] of Object.entries(change)) fs.writeFileSync(path.join(dir, file), text)
git(dir, 'add', '-A')
git(dir, 'commit', '-qm', 'box work')
const tree = git(dir, 'rev-parse', 'HEAD^{tree}')
const snap = git(dir, 'commit-tree', tree, '-p', 'HEAD', '-m', 'back')
git(dir, 'update-ref', `refs/cocoonbox/back/${id}`, snap)
git(dir, 'update-ref', `refs/${legacy ? 'cocoonbox' : 'box'}/back/${id}`, snap)
const outbox = path.join(tmp, 'state', `${key}.outbox.json`)
const entries = fs.existsSync(outbox) ? JSON.parse(fs.readFileSync(outbox, 'utf8')) : []
entries.push({ seq: entries.length + 1, kind: 'back', offload_id: id, cli: 'claude', time_ms: Date.now(), body: handover('done in the box') })
Expand Down Expand Up @@ -141,7 +144,7 @@ test('offload warns, then moves the task; back fast-forwards the box commits', {
assert.ok(!fs.existsSync(path.join(copy, '.env')), 'ignored files stay here')
assert.ok(!fs.existsSync(path.join(copy, 'node_modules')))
// the bundle carried history and S
assert.equal(git(copy, 'rev-parse', `refs/cocoonbox/offload/${r.offload_id}`), git(repo, 'rev-parse', `refs/cocoonbox/offload/${r.offload_id}`))
assert.equal(git(copy, 'rev-parse', `refs/box/offload/${r.offload_id}`), git(repo, 'rev-parse', `refs/box/offload/${r.offload_id}`))
assert.equal(git(copy, 'rev-parse', 'HEAD'), git(repo, 'rev-parse', 'HEAD'))
assert.equal(fs.readFileSync(path.join(tmp, 'state', `${r.repo}.handover.md`), 'utf8'), handover('finish the thing'))
const envJson = JSON.parse(fs.readFileSync(path.join(tmp, 'state', `${r.repo}.env.json`), 'utf8'))
Expand Down Expand Up @@ -195,10 +198,32 @@ test('back applies a clean merge uncommitted when the user worked meanwhile, els
fs.writeFileSync(path.join(repo, 'a.txt'), 'mine\n')
playBoxAgent(third, { 'a.txt': 'theirs\n' })
const conflict = JSON.parse((await cli(['back', '--json'], { cwd: repo })).text)
assert.match(conflict.landing, /^conflict: the box's work is on the branch cocoonbox\/back-/)
assert.match(conflict.landing, /^conflict: the box's work is on the branch box\/back-/)
assert.equal(fs.readFileSync(path.join(repo, 'a.txt'), 'utf8'), 'mine\n', 'the work tree is untouched')
})

test('a box on an image before the ref rename takes the offload and hands back', { skip, timeout: 60000 }, async () => {
const repo = makeRepo('proj-legacy')
fs.writeFileSync((await cli(['offload', 'handover-path'], { cwd: repo })).text.trim(), handover('old image'))
const old = await startFakeBox({ workspace: path.join(tmp, 'workspace'), state: path.join(tmp, 'state'), env: { ...IDENT, FAKE_BOX_LEGACY: '1' } })
try {
const extraEnv = { BOX_ENDPOINT: old.endpoint }
const done = await cli(['offload', '--kind', 'claude', '--yes', '--json'], { cwd: repo, extraEnv })
assert.equal(done.code, 0, done.stderr + done.text)
const r = JSON.parse(done.text)
const snap = git(repo, 'rev-parse', `refs/box/offload/${r.offload_id}`)
assert.equal(git(r.dir, 'rev-parse', `refs/cocoonbox/offload/${r.offload_id}`), snap, 'the old image gets S under its old name')
assert.equal(git(repo, 'for-each-ref', 'refs/cocoonbox/'), '', 'the old name is not kept in the user repo')
playBoxAgent(r, { 'a.txt': 'one\nfrom an old box\n' }, { legacy: true })
const back = await cli(['back', '--json'], { cwd: repo, extraEnv })
assert.equal(back.code, 0, back.stderr + back.text)
assert.match(JSON.parse(back.text).landing, /^fast-forward: /)
assert.equal(fs.readFileSync(path.join(repo, 'a.txt'), 'utf8'), 'one\nfrom an old box\n')
} finally {
await old.stop()
}
})

test('a box-side block exits 3 and names the box CLI commands', { skip, timeout: 60000 }, async () => {
const repo = makeRepo('proj3')
fs.writeFileSync((await cli(['offload', 'handover-path'], { cwd: repo })).text.trim(), handover('blocked'))
Expand Down
Loading