Add Tenki Sandbox as a cloud provider#243
Closed
francoluxor wants to merge 3 commits into
Closed
Conversation
* Add Tenki Sandbox as a cloud provider Adds `--provider tenki`: each box runs in a Tenki (tenki.cloud) Firecracker microVM driven over the official @tenkicloud/sandbox TypeScript SDK — a ConnectRPC control plane plus a per-session data plane for exec/file transfer, with session.ssh() for interactive attach. New packages/sandbox-tenki implements the CloudBackend contract (provision/lifecycle/exec/file-seed/preview/pause-resume/snapshot), composed into a full Provider via @agentbox/sandbox-cloud's createCloudProvider so it reuses workspace seeding, ctl/relay launch, state, and the host poller. Wired through the CLI, config keys, and docs. Validated end-to-end against a live Tenki workspace. Fixes from that pass: - provision: supply project_id (workspace tokens require it), resolved from AGENTBOX_TENKI_PROJECT_ID/_WORKSPACE_ID or the token's identity (whoAmI). - signedPreviewUrl: omit slug when a TTL is set (the API rejects slug+ttl). - file transfer: the guest-agent file RPC is jailed to the session workdir, so uploadFile/downloadFile now bridge absolute targets (e.g. /tmp seeding) through a workdir-relative stage plus an exec mv/cp. - publish the tenki attach-helper in the staged CLI runtime. - pin @tenkicloud/sandbox ^0.4.0 and handle the USER_SHUTDOWN state. * Address Tenki provider review - Add box.tenkiTimeoutMs config key (UserConfig/EffectiveConfig/defaults + key registry) and read it in cloudSizingProviderOptions instead of a hardcoded 45 min, matching the vercel/e2b pattern. - Checkpoint remove: delete the remote snapshot before dropping the local manifest. A persistent delete failure now surfaces and preserves the manifest so a billable, quota-limited snapshot isn't orphaned; not-found stays idempotent.
The Tenki provider added config keys that weren't reflected in the public docs or the user-config JSON schema: - Add box.imageTenki / box.sizeTenki / box.defaultCheckpointTenki / box.tenkiTimeoutMs to user-config.schema.json (editor/IDE hints). - List the Tenki per-provider keys alongside the other clouds in the configuration reference, and document box.tenkiTimeoutMs in the provider-tuning table and the Tenki page. Runtime validation already accepts these keys (they are in KEY_REGISTRY); this closes the docs/schema gap so the contribution guideline for changed config keys is satisfied.
|
@francoluxor is attempting to deploy a commit to the madarco's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Thanks for your pull request. Before it can be merged, please read our Contributor License Agreement and sign it by posting the comment below. You only ever have to do this once. I have read the CLA Document and I hereby sign the CLA You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot. |
* feat(provider): add DigitalOcean Droplet sandbox provider
Add a sixth cloud backend so boxes can run on DigitalOcean Droplets.
DigitalOcean Droplets are a VPS-over-REST+SSH model identical in shape to
the existing Hetzner provider, so this is a clone-and-adapt of
sandbox-hetzner with a hand-rolled DigitalOcean API v2 client.
New package packages/sandbox-digitalocean:
- client.ts: REST client over api.digitalocean.com/v2 (Bearer token) —
droplets, droplet actions + action polling, snapshots, firewalls,
pagination, typed DigitalOceanApiError.
- backend.ts: CloudBackend. Provision polls for active+public-IP (DO
returns neither at create), maps droplet status -> CloudState, polls
async actions, snapshots for checkpoints.
- firewall.ts: per-box Cloud Firewall created with a unique tag BEFORE the
droplet (DO auto-applies via tag at boot — no unprotected window) plus
mandatory allow-all outbound rules (DO blocks egress otherwise).
- credentials.ts: single Personal Access Token (DIGITALOCEAN_TOKEN),
validated via GET /account, persisted to ~/.agentbox/secrets.env.
- prepare.ts: bake the base snapshot (DO can't build from a Dockerfile).
- cli.ts: `agentbox digitalocean login|firewall sync|firewall show`.
- Provider-neutral helpers (ssh-*, poll, retry, egress-ip, runtime-assets,
prepared-state, install-box.sh) copied from Hetzner.
Wiring: ProviderKind + per-provider config keys (size/image/checkpoint)
in @agentbox/config (types, image, size, checkpoint, schema);
PreparedProviderKind in sandbox-core; provider registry, cloud-backend
resolver, relay resolveCloudBackend, CLI command registration, install
wizard, doctor checks, and runtime staging.
Auth = API token (no OAuth app / no doctl needed). Defaults:
s-2vcpu-4gb / nyc3 / ubuntu-24-04-x64.
Tests: REST client (mocked fetch), firewall rules, status mapping, plus
the ported provider-neutral suites (54 tests). Full build + lint +
typecheck green.
Claude-Session: https://claude.ai/code/session_011VoAz7mUaUGh6dKAvr7kAP
* feat(portless): start first-run proxy on :443 with a one-time root prompt
The first-run Portless setup deliberately started the proxy non-root with
`portless proxy start --no-tls -p 1355`, so box web apps came out as the ugly
`http://<box>.localhost:1355`. Portless already self-elevates for the default
HTTPS :443 proxy, so getting the clean `https://<box>.localhost` is mostly
about not suppressing that elevation.
`setupPortlessHost` now tries `startPortlessProxyRoot` first —
`portless proxy start && portless trust` on :443, surfacing a single password
prompt (a native macOS GUI dialog via `osascript … with administrator
privileges`; the terminal `sudo` prompt elsewhere). `trust` is bundled so the
host browser trusts the self-signed CA. If the user dismisses the dialog or
elevation fails, it falls back to the existing no-root :1355 proxy, so create
never breaks.
The elevated shell runs with a minimal PATH that misses nvm/user npm prefixes,
so the command resolves `portless` to an absolute path first. The Hetzner
silent path gates the dialog on `isTTY && !--yes` so scripted creates stay
quiet. An already-running proxy (:443 or :1355) is reused with no prompt.
Docs updated (web-apps-and-tunnels, features, cloud-providers).
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* fix(portless): shell-quote elevated portless path; drop misleading fallback port
Address Cursor Bugbot review on #155:
- The macOS `do shell script` embedded the resolved portless path unquoted, so
a home dir with spaces (`/Users/Jo Smith/.nvm/…`) would word-split and break
the :443 elevation. Single-quote the path for /bin/sh (survives the outer
AppleScript literal); verified compiling for paths with spaces, apostrophes,
and quotes.
- The :1355 fallback success line hardcoded the URL, which is wrong in the rare
probe-race where the root :443 proxy is actually the one that came up. Drop
the asserted port; the real URL is resolved via `portless get` at create.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* fix(cli): verify tray launched after install, retry once
installTray fired a single `open` and returned, but `open` resolves before an
LSUIElement menu-bar app finishes registering, and right after a `ditto` extract
Launch Services can briefly not resolve the freshly-written bundle — so the app
sometimes did not come up and the user had to launch it by hand.
Poll `pgrep -x AgentBoxTray` for ~3s after `open`; if still not running, retry
`open` once, then fall back to a clear 'launch it from /Applications' message.
Launch-at-login remains opt-in via the app's Settings toggle (unchanged).
* fix(hub): resolve hub deps via npm instead of a bundled pnpm store
A globally-installed `agentbox hub` crashed on startup with
`Cannot find package 'next'`. The published bundle shipped the Next
standalone `node_modules` as a pnpm store (`.pnpm` + relative symlinks),
which `npm publish`/`npm install` mangles so the installed `next` symlink
dangles. It only ever worked from a dev checkout, where Node resolves
`next` from the workspace node_modules.
- Declare the hub's runtime externals (next pinned to 16.2.7, react,
react-dom, better-auth, kysely) as real deps of @madarco/agentbox so
npm installs a flat, resolvable node_modules.
- Bundle the private @agentbox/sandbox-* providers into the hub server
(never published to npm, so unresolvable from a fresh install).
- Stop shipping runtime/hub/node_modules (~44M of dead, broken weight),
and guard against next-version drift between the compiled `.next` and
the declared dep at build time.
- spawnHub now fails fast on an early child exit and inlines the tail of
hub.log, so the real cause shows immediately instead of a ~25s timeout.
Verified via `npm pack` -> install into an isolated prefix -> run the
staged hub: healthz ui:true, GET / -> 200 full render, no errors.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* feat(cli): add `agentbox app log` for tray diagnostics
Collect the macOS tray app's diagnostics in one command for bug reports:
reads the tray's unified-log entries (`--last`, `-f` to stream, `--crashes`
for crash reports only), lists crash reports from
`~/Library/Logs/DiagnosticReports`, `--open` reveals that folder, and
`--out <file>` writes a self-contained bundle (versions + log + newest
`.ips`) to attach.
The tray keeps no log file of its own — these are macOS-native surfaces
(unified logging + OS `.ips` crash reports). Exports APP_BUNDLE_ID from
install-tray.ts as the logging subsystem.
Claude-Session: https://claude.ai/code/session_01WHtFJBjFg7n95j9jE9R4rb
* fix(cli): harden `agentbox app log` against edge cases (bugbot)
- trayUnifiedLog surfaces `log show` failures/timeouts as a note instead of
letting callers misread empty output as "no entries / tray not running".
- listCrashReports skips a report that vanishes between readdir and stat
rather than throwing the whole command.
- --follow resolves on the child's `error` event (spawn failure) so it can't
hang waiting for an `exit` that never comes.
Claude-Session: https://claude.ai/code/session_01WHtFJBjFg7n95j9jE9R4rb
* fix(cli): `app log` follow exit code + drop spurious bundle 'none' (bugbot)
- --follow now sets a non-zero exit status on spawn error or a genuine
non-zero child exit, so scripts don't read a broken `log stream` as success
(a user Ctrl-C kills by signal → still a clean exit).
- The --out bundle no longer prints a literal `none` under the crash section
when there are no crash reports.
Claude-Session: https://claude.ai/code/session_01WHtFJBjFg7n95j9jE9R4rb
* fix(cli): scan Retired/ + tolerate underscore in crash-report names (bugbot)
macOS moves older .ips reports into DiagnosticReports/Retired, so also scan
that subdir. Match `AgentBoxTray[-_]*.ips` (hyphen is what macOS uses today;
underscore tolerated for other report types), built from APP_NAME.
Claude-Session: https://claude.ai/code/session_01WHtFJBjFg7n95j9jE9R4rb
* feat(ssh): default-on ~/.agentbox/ssh/config via Include for SSH-capable boxes
Redirect per-box SSH Host blocks out of ~/.ssh/config into an AgentBox-owned
~/.agentbox/ssh/config, referenced by a single managed Include line. Regenerate
the file wholesale from state.json (self-heals stale/destroyed boxes) using a
persisted per-box cloud.ssh target, so the regen never hits a provider API.
Auto-write is default-on (config key ssh.autoConfig, default true) and fires on
create + start/resume — refreshing a Hetzner box's public IP across stop/start.
Explicit shell --ssh-config / code / open still write on demand regardless of
the toggle. Legacy inline blocks in ~/.ssh/config are stripped on next touch.
* fix(ssh): correct stale --ssh-config conflict warning for the Include model
With the managed Include prepended, agentbox's entry wins first-match, so a
user's own Host <alias> is now shadowed BY ours (not the reverse). Reword the
warning to match. (Cursor Bugbot)
* rename(cli): tray app AgentBoxTray → AgentBox
The macOS menu-bar app is now "AgentBox": `APP_NAME='AgentBox'`, so it installs
to `/Applications/AgentBox.app`, downloads `AgentBox.zip`, and pgrep/pkill by
the new executable name. Crash-report discovery keys on the new name too
(macOS writes `AgentBox-*.ips`).
`agentbox install tray` now removes any leftover pre-rename `AgentBoxTray.app`
(and quits it) so the two bundles — which share one id — never coexist.
Kept: the bundle id `com.madarco.agentbox-tray` (logging subsystem, login item,
notifications) and the `install tray` / `app` command names.
Pairs with madarco/agentbox-tray#3. A new tray release (emitting AgentBox.zip)
must be published before `install tray` resolves the renamed asset.
Claude-Session: https://claude.ai/code/session_01WHtFJBjFg7n95j9jE9R4rb
* fix(cli): `agentbox app` sees the legacy AgentBoxTray process too (bugbot)
status/stop/restart now cover both the current (AgentBox) and pre-rename
(AgentBoxTray) executable names, so a stray old process during migration is
reported by status and killed by stop/restart — matching the legacy cleanup
`install tray` already does. Exports LEGACY_APP_NAME from install-tray.ts.
Claude-Session: https://claude.ai/code/session_01WHtFJBjFg7n95j9jE9R4rb
* fix(cli): remove legacy bundle only after new app is installed (bugbot)
Deleting /Applications/AgentBoxTray.app happened right after pkill, before the
new zip was downloaded/verified/extracted — a failed install would strand a
user who only had the pre-rename bundle. Move the removal to after the ditto
extract succeeds (and into the uninstall branch), so we never delete the old
app until the new one is in place.
Claude-Session: https://claude.ai/code/session_01WHtFJBjFg7n95j9jE9R4rb
* feat(provider-sdk): extend public surface + publish as @madarco/agentbox-provider-sdk
Make the provider SDK sufficient to build a real, full-featured cloud provider
plugin (not just a stub), and rename it to a scope we own so it can ship to npm
(the @agentbox scope is unavailable).
- Re-export the surface a real provider needs, all inlined at build time:
- attach helpers (renderInnerCommand, hostTermForCloud)
- prepare-time agent-config staging (stageClaude/Codex/OpencodeStaticForUpload)
- id-addressed checkpoint authoring (writeCloudCheckpointManifest,
listCloudCheckpoints, resolveCloudCheckpoint, removeCloudCheckpointDir,
currentCloudBaseFingerprint)
- Widen PreparedProviderKind to accept a plugin's open-string provider name
(with a filename guard) so a plugin can manage its own ~/.agentbox/<name>-prepared.json.
- Add listCloudBackendDirs() so the CLI can surface plugin checkpoints.
- Rename @agentbox/provider-sdk -> @madarco/agentbox-provider-sdk; add README +
LICENSE + publish metadata (prepublishOnly, repository, files) and a pack-test
script that installs the tarball in isolation and asserts every export ships.
All additive within SDK v1 (SDK_API_VERSION unchanged).
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* feat(cli): add agentbox-provider-example plugin + plugin-aware checkpoints
Add a real, working provider plugin (Vercel-backed) built only on
@madarco/agentbox-provider-sdk, as the canonical copy-me reference and an
internal end-to-end test of the full plugin surface. Verified live: prepare bakes
a base snapshot, create brings a box to ready, exec/attach work, and the
id-addressed checkpoint override captures + lists + removes correctly.
- examples/agentbox-provider-example: faithful copy of the built-in Vercel
provider, imports only the public SDK; shared box-runtime pulled from the CLI
via resolveSharedRuntimeAsset, only provision.sh + custom-system-CLAUDE.md
vendored.
- checkpoint list/rm now enumerate on-disk cloud backends (allCloudBackends), so
a plugin provider's checkpoints show and can be removed (previously hardcoded
to the built-in CLOUD_PROVIDER_NAMES).
- docs: provider-plugins authoring + Publishing note; a worked design doc; and a
/release-notes step that flags when the separately-published SDK needs a
republish. Rename references to @madarco/agentbox-provider-sdk throughout.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* fix(cli): checkpoint `ls -g` + `rm` handle plugin backends (bugbot)
Two follow-ups to the plugin-aware checkpoint change, from Cursor Bugbot:
- `checkpoint ls -g` (global) still enumerated only the built-in CLOUD_BACKENDS,
so plugin-provider checkpoints showed in the project-scoped list but were hidden
globally. Use allCloudBackends() there too.
- `checkpoint rm` threw "checkpoint not found" when a ref WAS resolved on disk
(cloudHits) but every remove() failed — misleading. Distinguish a real
removal failure (report the backends that failed) from a genuinely absent ref.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* docs: public "Build a provider" reference page + README dev section
- apps/web/content/docs/build-a-provider.mdx: user-facing guide to building a
custom provider plugin on @madarco/agentbox-provider-sdk (the CloudBackend +
createCloudProvider + providerModule shape, cloud overrides, local build/test
commands, operate/trust/compatibility). Registered under the docs Reference nav.
- README: add a "Custom providers (plugins)" subsection under Development with the
local build/register/pack-test commands and links.
- provider-plugins.md: cross-link the public page.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* docs: invite provider authors to contribute a Community Providers page
Add a call-to-action near the top of the Build a provider page: once a provider
works end to end, open a PR to add a short page for it under "Community Providers".
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* release: v0.22.1
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* fix(portless): adopt a running proxy on non-interactive box creates
Boxes created non-interactively — from the tray app (hub -> queue worker),
`--yes`, or CI — never used Portless on a fresh machine, even with the proxy
live on :443. The queue worker resolved `portless.enabled` from config only
(undefined until the user opted in once from a terminal), and the foreground
non-TTY path returned false outright.
Add `resolvePortlessNonInteractive`: when Portless is undecided and the engine
is Docker Desktop, adopt an already-running proxy (persisting the choice) —
never install or start one unasked. Wire it into both `maybePromptPortless`'s
non-interactive branch and the queue worker's create path.
* fix(ssh): always write ~/.agentbox/ssh/config — from the CLI and the hub
A box created or resumed through the hub never got its ~/.agentbox/ssh/config
Host block: the SSH-config auto-write lived only in the CLI create/start/unpause
commands, and a silent `catch {}` hid the gap. Hub-created Hetzner boxes were
missing `ssh <box>` (and `cloud.ssh` was never persisted).
- Move ssh-config.ts + cloud-ssh.ts into @agentbox/sandbox-core so both the CLI
and the hub can call them. resolveCloudSshTarget/ensureCloudSshAlias/
autoWriteSshConfig now take the resolved Provider (was the CLI-only
providerForBox), and drop the @clack dep for an injected logger.
- CLI callers import from sandbox-core and pass their provider; the moved
ssh-config test moves with it.
- Wire the two hub-reachable choke points: runCloudJob (the worker the hub's
create enqueues) now writes the config after create — fixing the reported bug —
and hub-backend refreshes it on resume and drops it on destroy.
- Stop swallowing the failure: autoWriteSshConfig now surfaces the reason via a
logWarn callback (CLI stderr / hub log) instead of a bare catch.
Verified live on Hetzner: the refactored shared path rewrites the entry via
`shell --ssh-config`; sandbox-core tests + typecheck/lint/build all green.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* feat(update): detect package updates, refresh dependents, daily update nudge
When the user updates @madarco/agentbox themselves (npm update -g), the next
interactive command detects the version change via ~/.agentbox/update-state.json
and offers the post-update refresh (skills, box image, relay, menu-bar app)
with a Y/n prompt. self-update now also updates the tray app and reports
current-vs-latest; after a real package update the refresh runs in the
freshly-installed binary via the hidden _post-update-refresh worker so the
new version stamps itself.
The tray app updates by comparing the release's ~80-byte AgentBox.zip.sha256
sidecar against the sha stamped at install time — the zip is only downloaded
(and the running app only restarted) when the published build changed.
Network discipline: normal CLI calls never hit the network. At most once per
24h an interactive command fires one background probe (npm registry latest +
tray sidecar sha, 3-5s timeouts, errors swallowed) cached in the same stamp
file; the "newer agentbox available" nudge prints from that cache. New config
key update.check (bool, default true) disables the probe + nudge; the gate
falls back to the global config alone when the layered load fails (e.g. a
project config carrying provider-plugin keys).
The refresh never touches box state: state.json, containers, volumes, and
checkpoints are never read or written; verified live (containers unchanged,
state.json byte-identical, hub survived, tray reinstall only on sha change).
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* fix(update): address Bugbot findings on the update-check cache and wizard stamp
- merge the daily probe result with the previous cache so a partial probe
(one fetch failed) or a disabled check doesn't drop values cached earlier
- stamp checkedAt even when update.check is false so the daily gate still
throttles the background probe
- the install wizard only baselines a missing lastRunVersion; a mismatched
stamp is left alone so the post-update refresh is still offered on the
next eligible command
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* feat(box): rename via a cosmetic displayName label
Add box rename across the CLI, hub, and tray. Instead of mutating the
structural `name` (which drives the container name, git branch, and
Portless URL), rename sets a new optional `BoxRecord.displayName` — a
display/lookup-only label. `name`/`id`/container/branch/URL are untouched,
so a rename is a pure, provider-agnostic state edit that works in any box
state.
- core: `displayName` on BoxRecord; `setBoxDisplayName` (locked RMW,
blank clears); `findBox` resolves by displayName (lowest precedence).
- cli: `agentbox status <box> --set-name <name>` / `--clear-name`; `list`
shows `displayName ?? name` via a shared `boxLabel` helper.
- hub: `backend.rename` + `POST /api/v1/boxes/:id/rename`, `renameBoxAction`,
a Rename button, and `mapBox` prefers displayName as the box label.
- tray: per-box "Rename…" menu item over `status --set-name`; the custom
label wins in the row's primaryLabel.
- docs: cli.mdx, features.md, state.md, tray CLAUDE.md.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* fix(hub): enforce rename length cap in backend.rename
The web Rename button (renameBoxAction → backend.rename) called
setBoxDisplayName directly, bypassing the 60-char cap the CLI and the
REST route's parseRenameBox enforce. Apply the cap in backend.rename so
the single choke point both the server action and REST route funnel
through rejects over-long labels. (Cursor Bugbot)
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* feat(open): agentbox open --in codex|herdr|cmux|vscode + --targets probe
Open a box in a host app from one command (and one tray click):
- codex: writes the box's SSH alias (same gate as shell --ssh-config:
persistent-identity providers only, gated BEFORE any ssh-config write)
then auto-opens codex://settings/connections/ssh/add?name=<alias>.
shell --ssh-config now shares codexAddUrl and hints at the new command.
- herdr / cmux: auto-start the box, then open a new workspace in the app
running `agentbox attach <box> --inline`. Works from OUTSIDE the app:
Herdr via its well-known ~/.config/herdr/herdr.sock (env-only lookup
kept — the open path resolves the default itself and injects
HERDR_SOCKET_PATH so herdr-status RPCs can't leak into a Herdr the
user isn't in), cmux via its control CLI resolved from PATH or the
/Applications/cmux.app bundle (injected as CMUX_BUNDLED_CLI_PATH).
A cmux auth rejection surfaces an actionable socketControlMode /
socket-password error. --inline stops the in-app attach from spawning
a second tab; keepShell (new SpawnInNewTerminalArgs flag, extracted
composePaneCommand) leaves a live shell if the attach fails.
- vscode: delegates to the code command via the extracted runCodeOpen.
- open --targets [--json]: once-at-launch availability probe (installed
apps + per-app provider eligibility) for the tray's "Open In…" menu —
a stable machine surface. Detection is pure fs checks, seam-injected,
Linux-safe (detectOpenTargets in _open-in.ts).
Flag-less `agentbox open` keeps the Finder/sshfs behavior unchanged.
Verified live: --targets JSON; codex deep link on a Hetzner box (alias
written, Codex form opens) and the gate on docker (exit 1, no write);
herdr workspace with the claude agent attached in a single pane; cmux
workspace created via the bundled CLI (auto-launches the app); vscode
identical to agentbox code. 40 new/updated unit tests; the
@agentbox/ctl bootstrap flake under parallel turbo reproduces on the
base commit with this change stashed (pre-existing).
Claude-Session: https://claude.ai/code/session_01JKGc7YWFuXVJvXKNHYnHeB
* feat(open): add iterm2 to open --in targets
Same attach flow as herdr/cmux, through the existing AppleScript spawn
path (spawnInITerm2 already honors keepShell) — a new iTerm2 window
running `agentbox attach <box> --inline`. Detection = iTerm.app bundle
(darwin-gated), no provider restriction.
Verified live: --targets reports iterm2, `open test --in iterm2` opened
a window with the agent attached (window title "Claude Code").
Claude-Session: https://claude.ai/code/session_01JKGc7YWFuXVJvXKNHYnHeB
* fix(open): resolve the cmux bundle CLI from ~/Applications too
Bugbot: detectOpenTargets counted a ~/Applications/cmux.app install as
available, but resolveCmuxBinary only probed /Applications — the menu
would show cmux and the action fail with "cmux CLI not found".
Claude-Session: https://claude.ai/code/session_01JKGc7YWFuXVJvXKNHYnHeB
* release: v0.22.2
Claude-Session: https://claude.ai/code/session_01JKGc7YWFuXVJvXKNHYnHeB
* fix(update): detect npm/pnpm global installs invoked from the shell
A global bin run straight from the shell carries no npm_config_user_agent,
and argv[1] is the bin symlink — detectExecutionMethod classified the common
case as 'direct', so agentbox self-update skipped the package update entirely
('running from source — no global install to update') and never downloaded
the latest version. The old unit test masked it by passing a user-agent that
only exists when invoked through npm.
Resolve the bin symlink's realpath: npm globals live under
<prefix>/lib/node_modules/, pnpm globals inside a /.pnpm/ store; a dev
checkout (or the pnpm-register symlink) stays 'direct'. The update nudge is
tightened to npm/pnpm only so it never points at a self-update that would
skip.
Verified end-to-end: the real dist run via a simulated global layout
(bin symlink, no user-agent) now plans 'npm install -g @madarco/agentbox@latest';
the dev symlink still classifies direct.
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* fix(update): don't misread a project-local pnpm store as a global install
pnpm's global dir is project-shaped (<PNPM_HOME>/global/5/node_modules/.pnpm),
so matching the .pnpm store segment also matched project-local layouts and a
node_modules/.bin launch could have triggered 'pnpm add -g' over a local dep.
Match the /pnpm/global/ dir instead; local installs (and unrecognized custom
PNPM_HOME layouts) classify as direct, where self-update skips the package
step.
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* docs(release-notes): add the menu-bar-app publish check to the skill
The tray app publishes separately (tray-latest GitHub release, sha-compared
by install tray / self-update / the update nudge), so /release-notes now
always checks ../agentbox-tray for commits since its last tag, tells the
user alongside the changelog entry, and documents the publish flow incl.
the load-bearing AGENTBOX_NOTARY_PROFILE and the VERSION-bump commit.
Claude-Session: https://claude.ai/code/session_01JKGc7YWFuXVJvXKNHYnHeB
* Revert "docs(release-notes): add the menu-bar-app publish check to the skill"
This reverts commit e273241883f6c889a3f7bcd2f98f93e7f226ebc2.
* docs(release-notes): app publish check + explicit consent boundary
/release-notes now always checks ../agentbox-tray for commits since its
last tag and tells the user alongside the changelog entry when the
menu-bar app needs republishing (tray-latest sha drives install tray /
self-update / the update nudge), documenting the flow incl. the
load-bearing AGENTBOX_NOTARY_PROFILE and the VERSION-bump commit.
Consent is explicit: the bump argument authorizes only the changelog +
version commit, tag, and their push — the app publish, any branch
merge/fast-forward (e.g. main <- nightly), and any npm publish always
require asking the user first.
Claude-Session: https://claude.ai/code/session_01JKGc7YWFuXVJvXKNHYnHeB
* test(relay): de-flake the working-agent gate tests (poll, don't sleep 40ms)
The positive assertions gave the 10ms queue loop a fixed 40ms window; on
loaded CI runners the first tick can land after it — this failed three CI
runs across PRs #165 and #170 (different tests in the same describe each
time). Poll for the condition (2s cap) instead; negative assertions keep the
short sleep.
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* fix(open): detect VS Code/Cursor via .app bundle, not just PATH
The tray's Open In... menu hides VS Code on a clean Mac because
detectOpenTargets() probed PATH only for 'code'/'cursor'. A freshly
dragged-in VS Code or Cursor has no PATH shim until the user runs
"Shell Command: Install 'code' command in PATH", and the tray spawns
the CLI via a login shell that doesn't source ~/.zshrc, so PATH-only
detection misses common installs.
Add a macOS .app-bundle fallback (Visual Studio Code.app / Cursor.app in
/Applications or ~/Applications) to both detection and the launch path:
resolveVscodeCli() prefers the PATH shim, then the bundled code/cursor
binary, so 'agentbox code' / 'open --in vscode' launch via the real CLI
(avoiding the open scheme:// %2B-encoding bug) even with no shim.
Codex stays app-only + Hetzner-only by design (needs Codex.app for the
codex:// deep link and a persistent per-box SSH identity).
* feat(hub): add host 'open in' app launchers to the box detail page
The box detail 'Access' section now has an 'Apps' row that opens the box
in a host app (Codex, VS Code/Cursor, cmux, Herdr, iTerm2), mirroring the
tray's Open In menu. Each app shows only when installed on the host AND
eligible for the box's provider (e.g. Codex is Hetzner-only).
Host-GUI launches only make sense on the user's own machine, so this is
gated to a localhost hub on macOS; a remote (hetzner/vercel) or non-macOS
hub reports supported:false and the row hides.
Reuse without extraction: the hub server already receives AGENTBOX_CLI_ENTRY
(same as the relay's cp/checkpoint host actions), so the backend re-shells
'agentbox open <id> --in <app>' / 'open --targets --json' — reusing all the
CLI's SSH-alias / deep-link / terminal-spawn / IDE-launch logic verbatim.
- GET /api/v1/open-targets -> which apps are launchable (cached probe)
- POST /api/v1/boxes/:id/open -> launch { app }
- Box view-model gains 'provider' so the client can gate per-box.
- OpenAPI spec + hub docs updated.
* feat(hub): add a subtitle to the Access 'Apps' row
Match the Web/Git rows — a muted one-line description under the label.
* feat(hub): reword the Access 'Apps' subtitle
* docs: add islo.dev under Community Providers
Add a Community Providers section + an islo page documenting
`agentbox-provider-islo`, a community provider plugin for islo.dev microVMs
published separately at github.com/zozo123/agentbox-provider-islo (npm:
agentbox-provider-islo). Built on @madarco/agentbox-provider-sdk + @islo-labs/sdk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(hub): real docker base freshness + two-phase bake-then-create in the create modal
The docker provider's base-image freshness was hardcoded 'fresh' in the hub
(docker self-heals inside create), so a first-run or stale-base create hid a
multi-minute docker build inside the create job with no warning.
- packages/sandbox-docker: new evaluateDockerBaseFreshness() — a cheap,
read-only mirror of ensureImage's rebuild predicate (one docker image
inspect + context hashing), with the pure classifier unit-tested.
- hub backend: GET /providers?freshness=1 now reports docker
unprepared/stale truthfully (configured stays true — freshness is a
heads-up, never a gate). The hub spawn passes AGENTBOX_DOCKER_CONTEXT so
the bundled hub can fingerprint the same staged context as the workers.
- hub web: create modal shows a bake note under the provider picker;
docker auto-chains a prepare job ('Building base image…', streamed) into
the create on end:done; a stale cloud base offers Rebuild & Create /
Use Existing Image / Cancel (mirrors the CLI wizard). Settings badge
shows 'needs bake' for docker unprepared.
The CLI is unchanged: docker still self-heals silently there.
Claude-Session: https://claude.ai/code/session_01JKGc7YWFuXVJvXKNHYnHeB
* feat(hub): widen the REST Box payload for native clients + start lifecycle verb
GET /api/v1/boxes now carries the raw host-side fields (state, name,
provider, projectRoot, projectIndex, vncEnabled, gitWorktrees, per-agent
session titles, agent activity) so native clients (the tray app) can rely
on REST instead of shelling 'agentbox list'. Synthetic creating/error job
boxes carry name/provider/projectRoot too, and deliberately no 'state' -
its absence is how a client tells a failed create from a real box whose
agent errored. POST /boxes/:id/start brings a stopped box back up
(resumes when paused, no-op when running).
Claude-Session: https://claude.ai/code/session_01JKGc7YWFuXVJvXKNHYnHeB
* feat(docker): localhost sshd → agentbox open sshfs + Finder/Codex launchers (#175)
* feat(docker): localhost sshd + `agentbox open` sshfs + Finder/Codex launchers
Every docker box now runs an always-on OpenSSH daemon bound to container :22,
published only on 127.0.0.1:<ephemeral> and key-authed by a per-box ed25519 key
(minted under ~/.agentbox/boxes/<id>/ssh/, installed into the box's
authorized_keys). This powers a live-mount `agentbox open` and lets the Codex app
add a box as an SSH connection.
- Image: install openssh-server + agentbox-sshd-start (root, exec -d, idempotent),
staged via stage-runtime.mjs. No EXPOSE 22 (publish via -p; OrbStack keeps 6080).
- Provider: publish :22, mint key, install authorized_keys, launch sshd on
create + re-resolve the ephemeral host port on start (mirrors the VNC/web ports).
New packages/sandbox-docker/src/ssh.ts (setUpBoxSshd/launchSshdDaemon/
installAuthorizedKey). mintSshKey lifted to @agentbox/sandbox-core (shared with
Hetzner). Record lands on top-level BoxRecord.ssh {host,user,identityFile,port};
syncAgentboxSshConfig emits a Port line.
- open: `agentbox open` sshfs-mounts /workspace for SSH-capable providers
(SSH_MOUNT_PROVIDERS = docker/hetzner/daytona); vercel/e2b fail fast with a
readable pointer to `agentbox download`; legacy docker (no sshd) keeps the rsync
fallback. `--in codex` now works for docker.
- Finder launcher: `finder` is a first-class open target (open --targets --json),
gated to SSH_MOUNT_PROVIDERS, surfaced in the Hub "Apps" card and the Tray
"Open In…" submenu.
- doctor: optional (warn-not-fail) sshfs + macFUSE checks.
- Docs updated (features.md, access-your-box/cli/local-docker mdx).
* chore(cli): simplify the post-update prompt copy
* fix(ssh): backfill legacy cloud.ssh + refresh sshd port for a running box
Address two Cursor Bugbot findings on the SSH move:
- readState now backfills top-level `box.ssh` from a legacy `box.cloud.ssh`
on read, so an already-created box (e.g. Hetzner) keeps its managed
~/.agentbox/ssh/config alias — syncAgentboxSshConfig only reads `box.ssh`
and would otherwise drop it until the box's next start.
- `agentbox open` / `--in codex` on an already-running docker box now
refreshBoxSshd() before mounting: a `docker restart` (or docker daemon
restart) outside `agentbox start` reallocates the ephemeral -p 0:22 host
port, leaving the recorded Port stale. Extracted the start-path sshd
re-resolve into a shared, idempotent refreshBoxSshd() in lifecycle.ts.
Tests: readState backfill (+ no-clobber) in state.test.ts.
* fix(open): sshd-not-up alias guard, provider-independent --unmount, codex legacy msg
Second Cursor Bugbot pass:
- create + refreshBoxSshd only record `box.ssh` / re-sync ~/.ssh/config when sshd
actually bound (`up`), not merely when Docker published the -p 0:22 host port —
avoids advertising an alias that points at a dead loopback port.
- `agentbox open --unmount` is handled up front (provider-independent), so a
legacy docker box no longer falls through to the rsync path instead of tearing
down its sshfs mount.
- `--in codex` on a docker box that predates the in-box sshd now fails with a
clear "recreate the box" message instead of a confusing "sshd may have failed".
(The re-flagged "missing cloud.ssh migration" is already handled by the readState
backfill added in the previous commit + its tests.)
---------
Co-authored-by: smoke test <smoke@agentbox.test>
* feat(open): add Claude desktop to the 'open in' apps (CLI + hub)
'agentbox open <box> --in claude' registers the box in the Claude desktop
app. Claude has no codex-style add-SSH deep link, but it reads SSH
environments from its own settings — ~/.claude/settings.json sshConfigs,
where sshHost accepts an ~/.ssh/config alias — so the CLI upserts
{id: agentbox-<alias>, name: AgentBox: <box>, sshHost: <alias>,
startDirectory: /workspace} there directly (schema verified against
Claude.app v1.19367) and launches the app; the box appears in its
Environment dropdown, where the app can also list/resume the box's
existing Claude sessions over SSH.
The upsert (apps/cli/src/lib/claude-app-config.ts) matches by id,
preserves every foreign entry and settings key, and refuses to touch a
settings file that doesn't parse. 'agentbox destroy' prunes the box's
entry. The persistent-SSH gate + alias write is shared with codex via
ensurePersistentSshAlias (docker + hetzner boxes). The hub's box-detail
Apps row gains the Claude launcher (validator, backend types, UI entry).
Verified live on a hetzner box: settings diff is exactly one entry,
double-open stays a single entry, the alias connects over plain ssh, and
the hub POST /boxes/:id/open {app: claude} launches the app.
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* fix(open): prune Claude sshConfigs on every ssh-config sync, not just CLI destroy
Bugbot: hub/dashboard destroys call provider.destroy directly and skipped
the CLI-destroy-only prune, leaving dead environments in Claude. Move the
writer into @agentbox/sandbox-core and sweep agentbox-prefixed orphans
inside syncAgentboxSshConfig — every lifecycle path syncs (docker
destroyBox, CLI cloud destroy, create/start), so an entry left by a path
that skipped the sync self-heals on the next one. Foreign entries are
never touched; a corrupt settings.json never breaks lifecycle (best-effort
there, still fail-loud on explicit open --in claude).
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* release: v0.23.0
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* fix(star-prompt): remember any explicit answer, stop re-asking on every self-update
Previously only a confirmed gh-api star was persisted; answering N, or Y
without an authenticated gh (browser fallback), left the state untouched so
every self-update asked again. Any explicit Y/N now records answered:true
and permanently suppresses the prompt for both triggers.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* feat(hub): show AgentBox version in settings footer
Add a version field to the public /api/v1/health probe (read from the
inherited AGENTBOX_CLI_VERSION; apps/hub's own package.json is a 0.0.0
placeholder) and render a quiet footer line on the settings page that
fetches it client-side.
* fix(install): reject unknown targets, rename `install tray` to `install app`, name system warns
- `agentbox install <target>` with an unrecognized target now exits 1 with
the valid target list instead of silently launching the wizard (commander
allows excess arguments by default).
- `agentbox install tray` is renamed to `agentbox install app`, matching the
AgentBox.app bundle and the `agentbox app` lifecycle command; install-tray.ts
becomes install-app.ts. No alias.
- The wizard's compatibility one-liner now names what warned, e.g.
`system warn: optional sshfs, macfuse` instead of an opaque `system warn`.
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* fix(doctor): daytona unconfigured credentials say 'not configured', not the SDK paragraph
Matches the vercel/e2b rows; the detailed reason still surfaces in
`agentbox prepare --status`.
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* release: v0.23.1
* fix(image): heartbeat during docker pull's silent extraction phase
Piped (non-TTY) docker pull emits no output between the last 'Download
complete' and each 'Pull complete' — extracting the multi-GB box image
reads as a hang on first-run machines. Emit a keepalive line after 20s
of silence so the create spinner keeps moving.
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* release: v0.23.2
* fix(vnc): every open-VNC surface now starts the in-box browser first
Only `agentbox screen` (and the dashboard) ran the ensure-browser step, so
opening VNC from the tray app or the hub web UI landed on a blank X desktop.
- extract the prep into shared helpers: ensureBoxBrowserShowingApp
(sandbox-docker) and openWebAppOnVncScreen (sandbox-cloud, generic over
Provider); `agentbox screen` now uses both
- new hub action POST /api/v1/boxes/:id/screen runs the prep server-side
(docker + cloud); hub UI's Open VNC fires it before opening vncUrl
- tray app posts the same action (fire-and-forget) in openVNC
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* release: v0.23.3
* feat(open-in): always show open-in buttons, disabled with a reason tooltip
The hub Access card now lists every host app plus Open web/VNC and disables
unavailable ones with the why on hover (not installed, provider-ineligible,
box paused/stopped, no web service, VNC off) instead of hiding them. The
open-targets probe gains a reason field per unavailable app, and finder's
availability is now truthful: it requires sshfs on PATH (previously hardcoded
available, so the sshfs error only surfaced after clicking).
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* fix(sync): force host static-config stages user-writable
The static-config stages (`stage{Claude,Codex,Agents,Opencode}StaticForUpload`)
rsync the host's ~/.claude, ~/.codex, ~/.agents, and ~/.local/share/opencode
into a throwaway scratch dir, then rewrite it in place (filter settings.json,
sanitize config.toml, rewrite plugin paths) and finally `rm` it.
Every one of those rsyncs runs `rsync -a`, which implies `-p` and preserves the
*source's* modes. When the source is read-only — skill/plugin symlinks into the
Nix store (0444/0555), or any root-owned / mode-0444 dotfiles — the scratch copy
comes out read-only too. That breaks the stage two ways:
- the in-place `writeFile` rewrites fail with EACCES, and
- cleanup's `rm` can't unlink children of a 0555 dir:
`EACCES: permission denied, unlink '.../skills/1password/SKILL.md'`
which aborts `agentbox prepare` (and the docker/cloud seeds) for anyone whose
agent config is managed declaratively (Nix/home-manager, Ansible, chezmoi, ...).
A scratch dir has no business inheriting the source's perms, so add
`--chmod=Du+rwx,Fu+rw` to each stage rsync. GNU rsync honors it; macOS's
openrsync ignores it and doesn't hit the read-only-source case in practice, so
it's a safe no-op there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(hub): shadcn tooltip for disabled Access buttons, zero delay
Replace the native title attribute (slow OS delay, easy to miss) with a
radix-based shadcn Tooltip that opens instantly on hover, so the reason a
button is disabled is immediately discoverable.
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* release: v0.23.4
* feat(hub): dismiss errored (failed-create) boxes
Failed creates surface as synthetic `job:` boxes (status error, no state) backed
only by a queue manifest. They were stuck on screen — the REST lifecycle route
409'd all `job:` ids and destroy couldn't find them.
Reuse the destroy verb: for a `job:` id, destroy now deletes the failed job's
queue manifest (via deleteJob) when terminal, 409s when the create is still in
flight, and is idempotent when already gone. Narrow the route's `job:` guard so
destroy falls through while start/pause/resume/stop/screen still 409. The hub UI
renders a single Dismiss button for synthetic boxes (enabled only when errored).
* fix(hub): wrap job-dismiss destroy path in try-catch
Per Bugbot: the `job:` branch in destroy called readJob/deleteJob outside any
try-catch, so a non-ENOENT fs error would escape as an unhandled 500 instead of
the { ok:false, error } contract every other lifecycle action returns via
runLifecycle. Wrap it and map the error through errMsg.
* release: v0.23.5
* feat(web): update marketing home to Home v4 design
- Replace public/home.html with the AgentBox Home v4 design (inlined
feature-widgets.css/js so it stays a single self-contained static page).
- Keep the previous header + mobile hamburger menu and the live version
pill wired to /api/version; add an "app" nav entry.
- Interactive hero app mock: clicking the tray icon or "Quit AgentBox
Tray" toggles/closes the menu.
- Mobile fixes: meta strip stays on one line (hide os span); clouds/
agents/apps logostrip stacks into labeled rows.
- Fix swapped agent glyph PNGs in public/assets (correct Claude/OpenCode
mapping).
- Update apps/web/CLAUDE.md to describe the v4 home + design source.
* fix(web): keep meta strip on one line on desktop
Drop the stale hardcoded npm version from the meta strip — it pushed the
row past the frame width and wrapped the os span onto a second line. The
header pill already shows the live version via /api/version.
* feat(web): use the AgentBox SVG logo in the home header
Replace the ▣ glyph brand mark (which centers its inner box) with the
real #i-abx logo SVG, so the header shows the proper brand mark with the
inner box in the lower half.
* fix(codex): drop $comment from seeded hooks.json (codex strict parser rejects it)
Codex 0.134+ parses ~/.codex/hooks.json into a strict HooksFile struct that
only allows `description` and `hooks` at the top level; the seeded file's
$comment key made every box print 'failed to parse hooks config ... unknown
field $comment' on the codex TUI's first page (and silently disabled the
activity-state hooks). Rename the key to the legal `description` field.
Verified live in a box: warning gone, hooks parse clean on codex-cli 0.143.0.
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* feat(codex): carry git-marketplace plugins into boxes; stop clobbering box config
Three fixes to how ~/.codex reaches boxes, all providers:
- Sync the git-marketplace snapshots (.tmp/marketplaces/, ~13 MB) via
--include carve-ins ahead of the .tmp exclude. Without them in-box
`codex plugin list/add` fails with 'marketplace root does not contain a
supported manifest' and can't self-heal (the git shim blocks clone). The
heavy desktop-app payloads (.tmp/bundled-marketplaces 125 MB, .tmp/plugins
76 MB) stay excluded.
- Purge marketplace dirs under plugins/cache/ and .tmp/marketplaces/ that
the box config no longer references — previously every box got orphaned
caches (openai-bundled, openai-primary-runtime, stale test marketplaces)
incl. a node_modules with native prebuilds.
- Docker: config.toml is no longer rsynced (anchored --exclude=/config.toml);
reconcileVolumeCodexConfig owns it — sanitized host config as the base,
box-only [marketplaces]/[plugins]/[mcp_servers]/[projects] entries and
box-only top-level keys merged in additively (mergeCodexConfigForBox).
Previously every `agentbox codex` start wiped in-box `codex plugin add` /
`codex mcp add` state. Host wins on overlap (host-authoritative). The
orphan-purge keep-set comes from the merged config so box-installed
marketplaces keep their caches. Cloud is seed-once at prepare — no merge
needed there.
Verified live in a docker box: `codex plugin list` works, an in-box plugin
install + mcp add survive a full re-sync, a host-side value flip still
propagates, orphan caches are gone. rsync rule set fixture-tested (anchoring,
first-match-wins, nested .tmp still excluded).
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* docs: implementation plan for settings sync + credential fan-out
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* docs: record Phase 0 PoC results (token rotation confirmed single-use)
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* refactor(sync): shared agent-pull core in sandbox-core + Provider.syncTransport wiring
Move the claude-pull merge helpers to @agentbox/sandbox-core and extract the
inventory/delta/registry-merge logic of pullClaudeExtras into a shared plan
(computeClaudePullPlan) used by both the docker volume pull and the new
transport-based pulls (pull{ClaudeExtras,CodexConfig,OpencodeConfig}ViaTransport)
for cloud boxes. Wire the existing Provider.syncTransport seam in the docker and
cloud providers.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* feat(cli): download claude|codex|opencode from cloud boxes
The settings pull now works for any provider: cloud boxes are read over the
provider's SyncTransport (live box FS, auto-resuming a paused box) while docker
keeps the volume-based pull that works on stopped boxes.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* feat(cli): propagate pulled agent settings to other boxes
download claude|codex|opencode now offers a propagate step (same project /
all / none, or --propagate <scope>): items are staged from the source (docker
volume or live cloud box) and pushed additively into each target — the docker
shared volume once (covers every non-isolated docker box, running or paused),
isolated volumes individually, running cloud boxes over their SyncTransport
(claude plugin registries merged target-wins). Paused cloud boxes are skipped
with a note. Propagation works even when the host write is declined.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* feat(ctl): in-box credential watcher + box.credentialSync config key
agentbox-ctl now polls the three agent credential files (15s, mtime+hash) and
posts a 'credentials-updated' relay event with the fresh blob whenever one
changes and is shape-valid — the host relay will fan it out (next commit).
First scan posts too, so a refresh that happened while the relay was down
self-heals on daemon start. Gated by box.credentialSync (default true) /
--no-credential-sync, wired as AGENTBOX_CREDENTIAL_SYNC=0 into docker and
cloud creates. A drift test pins the watched paths + shape validation to
AGENT_SYNC_SPECS.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* feat(relay,cli): credential fan-out — newest-wins backup + propagate to all boxes
The host relay now handles the ctl watcher's 'credentials-updated' events:
validate shape, accept newest-wins (claude via strictly-increasing expiresAt;
codex/opencode last-writer-wins on content change), write the 0600 host backup
atomically, and spawn the hidden 'agentbox credentials propagate' CLI
(debounced per agent, serialized) which pushes the backup into every other
box: docker config volumes via helper containers (paused boxes included) and
running cloud boxes over their SyncTransport (owner via id -un, mode 0600).
The secret payload is special-cased on both ingest paths (direct POST /events
and the cloud poller) so it never lands in the event ring buffer.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* feat(cloud): reconcile agent credentials on box wake
reEnsureCloudBox (shared by resume/start/reconnect on every cloud provider)
now reconciles the box's agent credentials with the host backups: claude via
expiresAt in both directions (host newer → push; box newer → capture, the
box's watcher fans out on daemon start), codex/opencode host-wins push-if-
different, and a missing host backup is captured from a real box blob.
Best-effort and gated by box.credentialSync. Verified live: a paused vercel
box holding a rotated-dead claude blob came back with the fresh token after
pause/unpause.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* docs: settings sync + credential fan-out (public docs, repo docs, ship record)
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* fix(create): thread --no-credential-sync through CreateBoxRequest
The flag only updated the CLI-side effective config; both providers re-load
config without CLI overrides, so AGENTBOX_CREDENTIAL_SYNC=0 was never stamped
into the box. CreateBoxRequest now carries the caller-resolved credentialSync
(flag override included) and providers prefer it over their own config load
(which remains the fallback for entry points that don't thread it). Verified
live: a docker box created with --no-credential-sync carries
AGENTBOX_CREDENTIAL_SYNC=0.
Found by Cursor Bugbot on #182.
Claude-Session: https://claude.ai/code/session_01TGoDQvktdXD5rqJuJm9Uyk
* fix(carry): suppress macOS AppleDouble stubs; carry e2b + codex/opencode creds
Both carry paths tar'd the host source without COPYFILE_DISABLE=1, unlike
every other tar call in the repo. On a macOS host BSD tar then emitted a
`._<name>` resource-fork stub beside each carried file — 14 of them landed
in ~/.agentbox, ~/.codex, ~/.config/{notion,linear} and the vercel CLI store
of a live box, at mode 0644 rather than the entry's declared 0600. They hold
only xattrs (com.apple.provenance), no secrets.
agentbox.yaml's carry: block predates the e2b provider, so it shipped
E2B_API_KEY via secrets.env while omitting ~/.agentbox/e2b-prepared.json —
`agentbox doctor` in-box reports "base template not baked". Also carry the
codex/opencode newest-wins credential backups the relay's fan-out maintains:
the cloud stagers prefer them over the real auth.json paths, so without them
a nested box bootstraps from a possibly staler token.
Claude-Session: https://claude.ai/code/session_01AffCU7Rnv1bC9jXMJXFaer
* fix(git-shim): pass local/file:// clones through to real git
The in-box `git` shim intercepts every `git clone` and routes it to
`agentbox-ctl git clone` -> host relay. But agentbox's own cloud workspace
seeding runs a purely local, credential-free
git clone --no-checkout --quiet [--depth=N] file:///workspace <tmpdir>
(`runShallowClone`, packages/sandbox-cloud/src/sync/workspace-seed.ts) via
`execa('git', ...)`, which resolves through PATH and hits the shim. The clone
allowlist is `--branch|--depth`, so it died with
agentbox git shim: unsupported flag '--no-checkout' for 'git clone'
breaking `agentbox create --provider {vercel,hetzner,daytona,e2b}` whenever
agentbox itself runs inside a box.
Widening the flag allowlist would be wrong: `agentbox-ctl git clone` only
speaks github ("Host runs git clone with its creds"), so relaying a `file://`
clone is a semantic mismatch, not merely a strictness one. Instead, identify
the clone source before the flag gate and exec the real git when it is a
filesystem path or `file://` URL -- those need no host credentials. Remote
URLs and `owner/name` shorthand keep the gate unchanged, so it stays
fail-closed for anything credential-touching.
This is not a privilege boundary: `/usr/bin/git` is already on PATH behind
the shim, so a local clone was never something the shim prevented.
Verified live: with the fixed shim as PATH's git, `create --provider vercel`
from inside a box now seeds /workspace on the right branch; the repo's own
`packages/ctl/test/bootstrap.test.ts` (which shells out to `git clone --bare`)
also stops failing in-box.
Claude-Session: https://claude.ai/code/session_01K3PimZHyJjxVhuCfbHoZqX
* fix(git-shim): classify every clone token, not a guessed positional
Bugbot caught a real regression in the previous commit. The pre-scan found the
clone source by skipping values only for the flags it modelled (--branch,
--depth). Options it did not model take a value too -- `--reference <path>`,
`--separate-git-dir <path>`, `--upload-pack <cmd>` -- so that path landed as
the "source", matched the local pattern, and exec'd the real git with the full
argv. A remote clone then slipped BOTH the relay and the strict whitelist:
git clone --reference /tmp/ref https://github.com/x/y.git
-> nightly: "unsupported flag '--reference'" (loud)
-> HEAD~1: "Cloning into 'y'..." (silent bypass)
Not a credential leak (the box holds no git creds, and /usr/bin/git sits right
behind the shim on PATH), but it turned a clear rejection into a confusing
one, which is exactly the fail-open an argv gate must not do.
Stop guessing flag arity. Classify every non-flag token -- flag values included
-- and fall through only when something is unmistakably local AND nothing looks
remote (scheme://, scp-style host:path / user@host:path, or the bare
`owner/name` shorthand ctl accepts). Anything unknown or ambiguous -- including
a source we cannot classify at all -- takes the gate and is rejected loudly.
Fail-closed by default; unmodelled flags can no longer desync the parse.
Adds regression tests for --reference / --separate-git-dir / --upload-pack /
--reference-if-able before a remote, scp-style + ssh:// remotes, and an
unclassifiable source. Local seeding (`--no-checkout --quiet --depth=N
[--branch <b>] file:///workspace`) and bare local clones still fall through.
Claude-Session: https://claude.ai/code/session_01K3PimZHyJjxVhuCfbHoZqX
* fix(login): drive agent sign-in under a pty, prompt on the host
`agentbox <agent> login` handed the user's terminal straight to the agent's
own in-container TUI (`docker run -it`, stdio: 'inherit'). That TUI negotiates
raw-mode and enhanced-keyboard sequences with whatever emulator it finds, and
we control none of it: on kitty (CSI-u keyboard protocol) claude's paste-code
prompt stopped accepting input once the browser stole focus.
Guided mode is now the TTY default for claude, codex, and opencode. The login
container runs under node-pty, its raw stream goes to the command log, and we
reproduce the interaction with our own clack prompts on the host — identical in
every terminal. `--interactive` opts back into the passthrough (also the
automatic fallback when the optional node-pty prebuild is absent).
A terminal allowlist ("headless unless iTerm/tmux") was rejected: it hardcodes
today's known-good set and breaks on the next emulator.
- lib/agent-login-specs.ts pure per-agent prompt detectors, unit-tested
against real captured transcripts
- lib/agent-login-run.ts the pty loop, agent-agnostic
- lib/agent-login-bindings.ts per-agent docker argv + verify/finalize
- lib/guided-login.ts the host-side clack prompts
- lib/claude-login-run.ts now a thin wrapper, so the --headless worker and
the hub create-job worker are untouched
Per agent: claude pastes a code; codex's device flow needs no keystroke (so it
now works with no TTY); opencode is bounded by its per-provider prompt tree —
we skip its picker via --provider and drive the API-key / OAuth shapes, falling
back to the passthrough on anything else (e.g. github-copilot's nested select).
The first-run sign-in offers (`agentbox claude`, hit far more often than the
subcommand) route through the same seam.
Design + captured transcripts: docs/agent-login-guided-plan.md
Claude-Session: https://claude.ai/code/session_01AikKYvWcp1gV4vb2uqATTA
* fix(cli): type `scope` so the propagate picker's 'none' fits
`pnpm typecheck` has been failing on nightly since 63c2795b5: the early
`opts.scopeFlag === 'none'` return narrows the flag to 'project' | 'all' |
undefined, so the inferred `scope` could not hold the 'none' the interactive
picker returns. Annotate it with the full PropagateScope.
Unrelated to this branch's login work — it just blocks CI on every PR.
Claude-Session: https://claude.ai/code/session_01AikKYvWcp1gV4vb2uqATTA
* fix(login): word the retry hint for the input that was rejected
The core reused the OAuth wording for every rejected input, so an opencode
API key refused during `exchanging` told the user to "paste a fresh one".
Derive the message from the pending LoginNeed instead.
Reported by Cursor Bugbot on #186.
Claude-Session: https://claude.ai/code/session_01AikKYvWcp1gV4vb2uqATTA
* docs: box-sizing implementation plan (unified --size, hetzner preflight + errors)
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* fix(git-shim): allow harmless read-only flags; route pull --ff-only to the merge (#185)
* fix(git-shim): allow harmless read-only flags; route pull --ff-only to the merge
`git fetch --quiet` exited 2 with "unsupported flag", breaking user scripts
under `set -e`. The per-op allowlists in the in-box `git` shim were one flag
each (--force-with-lease / --ff-only / --prune). Widen them to the value-less,
non-destructive flags: --quiet/-q, --verbose/-v, --progress/--no-progress and
--dry-run across push/pull/fetch, plus --tags/--no-tags/--prune-tags/--force
where the op accepts them.
The gate stays fail-closed. Every push/pull/fetch flag must be value-less: the
glued form (--upload-pack=/bin/sh) fails the exact-match regex and the split
form (--depth 1) has its value caught by refuse_positionals, so
command-executing flags are excluded for free. --delete/--mirror,
push --prune and push --force stay off the list deliberately.
Also fixes a latent bug found alongside it: --ff-only, pull's only allowed
flag, was forwarded after a `--` separator, so commander left opts.ffOnly
unset and appended it to the host-built `git fetch <remote> <branch>`, which
rejects it ("unknown option `ff-only'"). partitionPullArgs now routes it to
the local merge, fetch flags to the fetch RPC, and --quiet to both.
The shim is baked into every image/snapshot, so cloud boxes need a re-run of
`agentbox prepare --provider <name>` to pick this up.
Claude-Session: https://claude.ai/code/session_0138cdMrFoxoYWSQCBAMsgUX
* fix(ci): repair pre-existing typecheck error + in-box bootstrap test
Both failures predate this branch and block CI on every PR into nightly.
`_agent-propagate.ts`: the early `opts.scopeFlag === 'none'` guard narrows
scopeFlag to 'project' | 'all' | undefined, so the inferred type of `scope`
couldn't hold the 'none' the interactive picker still returns. Annotate the
declaration. Three TS errors collapse to zero; runtime behavior unchanged.
`bootstrap.test.ts`: failed only when the suite runs inside a box. Plain `git`
on PATH is the agentbox shim, which intercepts `clone`, and the bind-mounted
host ~/.gitconfig sets commit.gpgsign against a key path absent in the box.
Put real git in front on PATH and neutralize the ambient config, restoring
both in afterEach. No-op outside a box, where /usr/bin/git is already real.
Claude-Session: https://claude.ai/code/session_0138cdMrFoxoYWSQCBAMsgUX
* feat(sizing): unify --size surface across providers (phase 1)
Phase 1 of docs/box-sizing-plan.md.
- config: delete box.vercelVcpus (unreleased, clean delete); add
box.hetznerLocation (default nbg1); rewrite the per-provider size
descriptions so vercel/e2b/daytona say what --size actually means.
- cloud-sizing: cloudSizingProviderOptions(provider, cfg, flags) now emits
`size` for every provider (flag > resolveBoxSize) plus hetzner `location`,
and is the single sizing entry point.
- create: drop the local size resolution, thread --size/--location through;
--location warns on non-hetzner providers.
- queued cloud creates now size identically to the foreground path.
- vercel: parseVercelVcpus rejects unsupported counts and foreign size specs
before the billable Sandbox.create call.
Claude-Session: https://claude.ai/code/session_01S8gNAvmLAgAozgHBjVT5aH
* feat(sizing): hetzner preflight, error mapping, location, real resources (phase 2)
Phase 2 of docs/box-sizing-plan.md.
- client.ts: add listServerTypes() (GET /server_types, paginated) and
HetznerServerType; add optional architecture to HetznerImage. Field shapes
verified against docs.hetzner.cloud/cloud.spec.json.
- new preflight.ts (pure, no network):
- validateServerChoice(choice, catalog, image) throws UserFacingError in
order: type exists (else suggest x86 non-deprecated names) → x86-only
(cax*/ARM clear message) → not deprecated → disk >= image.disk_size (skip
for stock string refs) → location in prices[].location.
- mapHetznerProvisionError maps resource_limit_exceeded → account-limit
guidance + Hetzner Console, resource_unavailable/placement_error →
try-another-location; preserves original messages, passes others through.
- backend.provision: resolve location from req.location; run preflight BEFORE
firewall/ssh-key creation (getImage for numeric refs only); use location in
createServer + progress line; wrap createServer with mapHetznerProvisionError;
return real resources {cpu,memory,disk} from the create response.
- CloudHandle + BoxRecord.cloud gain optional resources; cloud-provider stores
them and logs 'provisioned N vCPU / N GB RAM / N GB disk'.
- PrepareOptions gains location; prepare command gains --location (resolved
from box.hetznerLocation); prepareHetznerProvider forwards it.
- vercel: parseVercelVcpus error now names the rejected value and points at
box.sizeVercel / clearing box.size (addresses PR #187 bugbot finding 2).
- new preflight.test.ts: every validateServerChoice branch + every
mapHetznerProvisionError code + passthrough.
Claude-Session: https://claude.ai/code/session_01S8gNAvmLAgAozgHBjVT5aH
* docs: box-sizing implementation plan (unified --size, hetzner preflight + errors)
Claude-Session: https://claude.ai/code/session_0156d47n9hxNTCAuyjX7rEch
* feat(box): ship docker compose + buildx CLI plugins in every base image
The docs advertise Compose stacks over the in-box dockerd, but no provider
installed the plugin: `docker compose` was not a command and `docker build`
ran on the deprecated legacy builder. Ubuntu bases (docker/daytona via
Dockerfile.box, hetzner via install-box.sh) add the noble docker-compose-v2 +
docker-buildx packages; Debian 12 (e2b) and AL2023 (vercel) have no distro
plugin packages, so their bake scripts install the official release bina…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Add Tenki Sandbox as a cloud provider: Adds
--provider tenki: each box runs in a Tenki (tenki.cloud) Firecracker microVM driven over the official @tenkicloud/sandbox TypeScript SDK — a ConnectRPC control plane plus a per-session data plane for exec/file transfer, with session.ssh() for interactive attach.New packages/sandbox-tenki implements the CloudBackend contract (provision/lifecycle/exec/file-seed/preview/pause-resume/snapshot), composed into a full Provider via @agentbox/sandbox-cloud's createCloudProvider so it reuses workspace seeding, ctl/relay launch, state, and the host poller. Wired through the CLI, config keys, and docs.
How it was tested
Validated end-to-end against a live Tenki workspace. Fixes from that pass:
Checklist
pnpm lint && pnpm build && pnpm typecheck && pnpm testpassesapps/web/content/docs/)