Skip to content

Release#255

Open
madarco wants to merge 119 commits into
mainfrom
nightly
Open

Release#255
madarco wants to merge 119 commits into
mainfrom
nightly

Conversation

@madarco

@madarco madarco commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Note

High Risk
Large behavioral shift (default cloud creates on remote hub, auto-adopt, merged destroy/prune/approvals) plus release/CI dist-tag guardrails—mistakes could affect stable users or leave ghost hub state.

Overview
This PR is a pre-release cut (0.28.0-nightly.…) with a full [Unreleased] changelog covering the control box, nightly channel, and many CLI/hub fixes.

Control box as default path. With a remote hub configured, cloud create and foreground claude/codex/opencode route to the control box (cloud.viaHub, --via-hub / --local). New helpers enqueue jobs, poll, adopt locally, and run background -i entirely on the VPS. agentbox control-plane * is removed in favor of agentbox hub * (setup, deploy, custody, credentials, boxes, prompts, jobs, adopt/pull/target).

Local adoption. Box resolution auto-adopts hub boxes by name; ls / dashboard merge remote listings; approvals and relay URLs follow each box’s plane; destroy/prune reaps hub registration; prepare can share/adopt baked bases via custody.

Nightly channel. Docs and /release-notes nightly flow; CI and box-image run on nightly but do not move latest on images or npm; tray install respects stable vs nightly release tags; README documents @nightly and self-update --channel.

Other notable changes: ws added as a direct dependency; Portless brought back before hub/box .localhost URLs; cloud attach helpers moved to @agentbox/sandbox-cloud; docs map updated (control-box plan, nightly plan); install trayinstall app in README.

Reviewed by Cursor Bugbot for commit d1a422f. Configure here.

madarco added 30 commits July 16, 2026 20:57
Records the verified driver decision: drizzle-orm 0.45.2 has no node:sqlite
driver, so SQLite goes through drizzle's sqlite-proxy over node:sqlite
(no native dep), and Postgres moves onto drizzle-orm/node-postgres.

Claude-Session: https://claude.ai/code/session_01Xqzoz8sFavVVTEJ4z9WYJS
One drizzle schema (boxes/events/box_status/prompts/create_jobs) in both
dialects, and two stores over it:

- SqliteStore — node:sqlite (DatabaseSync) behind drizzle's sqlite-proxy
  driver, DB at ~/.agentbox/hub/store.db, WAL + busy_timeout, schema applied
  lazily on first open. drizzle ships no node:sqlite driver and its
  better-sqlite3 one would add a native dep, so the ~20-line proxy executor is
  the adapter. Guarded with a clear error on Node < 22.5.
- PostgresStore — ported onto the same schema via drizzle-orm/node-postgres;
  every hand-written SQL query is gone (the idempotent CREATE TABLE DDL stays,
  since drizzle-kit migration folders don't survive tsup bundling).

Both drivers stay lazy + external: relay's bin.cjs carries neither store, and
the CLI bundle keeps pg and node:sqlite out of its load path.

makeStore() gains a sqlite branch (sqlite: URL or a bare path). SqliteStore runs
the existing store conformance suite (pure, no docker/network) plus an on-disk
restart case; the Postgres fixture now truncates create_jobs too, so its queue
case no longer passes only against a virgin database.

Claude-Session: https://claude.ai/code/session_01Xqzoz8sFavVVTEJ4z9WYJS
The hub server now resolves the relay's store from the profile: an explicit
RELAY_STORE_URL/POSTGRES_URL wins, the hetzner (control box) profile otherwise
defaults to SQLite at ~/.agentbox/hub/store.db, and localhost keeps the
in-memory store. A control box on a small VPS therefore needs no database
container, and its registry/approvals/queue survive a restart.

Claude-Session: https://claude.ai/code/session_01Xqzoz8sFavVVTEJ4z9WYJS
Records what shipped (createRequire for node:sqlite, verified bundle posture,
optional Store.migrate, the pre-existing pg fixture bug), the verification
matrix, and opens a Backlog section — chiefly that the daemon's loops and the
hub backend still read in-memory state, which phase 3 must route through the
Store. Also refreshes the Store seam paragraph in host-relay.md.

Claude-Session: https://claude.ai/code/session_01Xqzoz8sFavVVTEJ4z9WYJS
A wholesale vi.mock factory undefines every other export of the module for the
whole graph, so it breaks as soon as any transitive import needs one — which is
what happened when the relay's new SqliteStore started reading STATE_DIR
(restore-agent-sessions died at import with "No STATE_DIR export is defined on
the @agentbox/config mock"). Spread importOriginal() and override only the
function under test; portless-prompt had the same latent footgun.

Claude-Session: https://claude.ai/code/session_01Xqzoz8sFavVVTEJ4z9WYJS
…box SSH keys)

Phase 2 of the control-box plan. A path-and-bytes CustodyStore seam with a
filesystem backend (FsCustodyStore at ~/.agentbox/hub/custody/, 0700 dirs /
0600 files, content-hash skip) exposed over a shared /admin/custody/*
dispatcher mounted in both the hosted-plane handler and the relay daemon. The
routes are admin-bearer-gated (not loopback-gated — a control box behind Caddy
makes every request look loopback) and fail closed; values are never logged.

The agent-credential set reuses AGENT_SYNC_SPECS (the same registry a cloud
create seeds from) rather than a second file list. CLI on `agentbox
control-plane`: credentials push, secrets push, custody pull/list; an
opportunistic hash-skipped push rides `credentials propagate`.

Tests: fs store + both handlers over in-process HTTP (relay), pure planner +
fake-fetch client (cli). Full pnpm test / typecheck / lint green.

Claude-Session: https://claude.ai/code/session_01TEBCAjM2JmmhztkhN1Svcc
Full-hub Hetzner deploy + resident create worker. Resolves the two phase-3
blockers (write-through durable store into the in-memory caches + boot
hydration so loops/hub-backend see state; retention sweep for answered
prompts + finished jobs) and details the six deploy/worker/firewall/enqueue
items, files, tests, and the in-box docker smoke.

Claude-Session: https://claude.ai/code/session_017WVUdi7NrV6dxFvezZPLV7
…lockers)

Blocker A: WriteThroughStore mirrors a durable store's writes into the
in-memory registry/events/statusStore the daemon loops + hub backend read
synchronously, and hydrates them on boot. So a hetzner+SQLite hub shows agent
status and its cloud-keepalive loop sees boxes. localhost/MemoryStore path is
unchanged (the wrapper is only built when a durable store is injected).

Blocker B: optional Store.prunePrompts/pruneCreateJobs (SQLite+Postgres, using
expires_at/finishedAt) + a periodic startRetentionLoop in the daemon so an
always-on control box doesn't accumulate answered prompts / finished jobs.

Claude-Session: https://claude.ai/code/session_017WVUdi7NrV6dxFvezZPLV7
…ore/handler

Extract the create-queue enqueue/status endpoints into a framework-agnostic
handleRemoteBoxesRequest (mirrors phase 2's custody shared-dispatcher), mounted
in BOTH the relay daemon (server.ts, what the control box runs) and the Vercel
plane handler. Admin-bearer gated, fail-closed. The control box's full hub now
serves POST/GET /remote/boxes, so the PC can enqueue against it.

Claude-Session: https://claude.ai/code/session_017WVUdi7NrV6dxFvezZPLV7
…e creates

- Promote makeControlPlaneCreateBox + CreateBoxDeps into @agentbox/relay so both
  the CLI worker command and the hub can build a CreateBoxFn.
- apps/hub/lib/hub-worker.ts: resident in-process worker (gated on
  AGENTBOX_HUB_WORKER=on) draining /remote/boxes. Its CreateBoxFn seeds agent
  creds from the custody agents/ scope into host backups before provider.create,
  registers the box on this hub (control-plane topology), and mirrors minted
  hetzner/DO box SSH keys into custody boxes/<id>/ssh/. A mockCreate seam
  (AGENTBOX_HUB_WORKER_MOCK=1) drives the queue offline for the in-box smoke.
- Dual-IP firewall: thread extraInboundCidrs through CloudProvisionRequest; the
  hetzner backend appends the admin PC egress CIDR to the box firewall sources
  for control-plane-topology creates (unless inbound is open). Fail-loud egress
  detection unchanged.

Claude-Session: https://claude.ai/code/session_017WVUdi7NrV6dxFvezZPLV7
New --via-hub / --url flags: instead of building locally, resolve the control
plane target, POST the create job to /remote/boxes (repo origin + provider +
branch/name), then stream GET /remote/boxes/:id until done/failed. Cloud
providers only; needs a configured control plane + admin token. The resident
hub worker provisions the box VPS-side.

Claude-Session: https://claude.ai/code/session_017WVUdi7NrV6dxFvezZPLV7
…command

- Dockerfile: build + run the standalone server.ts (relay + Next UI + worker),
  add git/openssh-client for the worker's clone + hetzner ssh. CMD runs the
  standalone bundle; server.ts self-migrates auth/store on boot.
- docker-compose.yml: app-only, hetzner profile, SQLite, worker on, persistent
  /root/.agentbox host-bind volume (store.db/auth.db/custody/secrets.env). Drop
  the Postgres container.
- control-plane-deploy.ts: scp the provider-secret subset (HCLOUD/E2B/DAYTONA)
  from the host secrets.env into the data volume; write AGENTBOX_HUB_DATA_DIR /
  PUBLIC_URL / ADMIN_CIDR (== deploying PC egress) into the VPS .env.
- New `agentbox control-plane deploy hetzner [--ref] [--repo]`: re-deploys the
  full hub reusing the existing App creds/env (no manifest flow), pinning the
  VPS clone to --ref for feature-branch live verify.

Claude-Session: https://claude.ai/code/session_017WVUdi7NrV6dxFvezZPLV7
…l box

- Record phase 3 as code-done in the plan (checkboxes, "what actually shipped",
  smoke results), mark blockers A/B addressed in the Backlog, add phase-3
  follow-ups, and mark the live-hetzner verify PENDING-HOST.
- Update apps/web control-plane.mdx: the Hetzner deploy is now the full "control
  box" (standalone hub + resident worker + SQLite, no Postgres); document
  `control-plane deploy hetzner [--ref]` and `create --via-hub`.

Claude-Session: https://claude.ai/code/session_017WVUdi7NrV6dxFvezZPLV7
The standalone esbuild bundle inlines the @agentbox/sandbox-* packages but
keeps their cloud SDKs (e2b, @daytona/sdk, @vercel/sandbox) external; pnpm's
strict layout has no /repo/node_modules/<sdk>, so the resident worker's
provider import died on the control box ('Cannot find package e2b'). Declare
the SDKs as apps/hub dependencies so they resolve via apps/hub/node_modules —
the same path pg/better-auth/next already use. Found in the phase 3 live
hetzner verify (the in-box mock smoke cannot reach a real provider import).

Claude-Session: https://claude.ai/code/session_01QnaMFuHVZUdTq5VzxiSg2L
…lers

The control box's resident worker registers a freshly created box via the
public plane URL, and the PC will do the same in phase 4 — but server.ts kept
/admin/* strictly loopback-only, so registration 403'd and the box was left
with an unknown token (push/status dead). Allow a timing-safe admin-bearer
match as the non-loopback alternative, same posture as core/handler.ts and the
custody//remote dispatchers. Fail-closed: a laptop relay configures no admin
token, so its gate stays loopback-only.

Found live on the deployed control box (phase 3 verify: 'unknown box token'
on agentbox-ctl git push; hub logs: 'plane register-box → 403').

Claude-Session: https://claude.ai/code/session_01QnaMFuHVZUdTq5VzxiSg2L
…llow)

A control-plane box's git.lease-token is auto-allowed only for the box's
sanctioned agentbox/* branch, which the gate reads from the REGISTRATION's
worktrees — but registerBoxWithPlane never sent any, so every hub-created
box's push blocked on a human approval (in-box push hung until timeout).
Register the /workspace worktree (branch = sanctionedBranch = agentbox/<name>)
at both the create and resume call sites. hostMainRepo is the create-time seed
checkout; on the hub worker that temp clone is deleted after create, so
host-side git RPCs against it stay dead — recorded in the plan backlog (the
lease path, which control-plane boxes actually use, never touches it).

Found live on the deployed control box (phase 3 verify).

Claude-Session: https://claude.ai/code/session_01QnaMFuHVZUdTq5VzxiSg2L
leaseAndPush spawned bare 'git push <remote> <branch>', which on a baked box
resolves to the agentbox git shim — and the shim refuses positional
remote/branch for push (by design), so every control-plane box's leased push
died with 'positional origin not allowed'. Use runRealGit (the direct-mode
helper that pins /usr/bin/git / AGENTBOX_REAL_GIT_PATH) for the set-url/push/
restore sequence, mirroring direct mode. The June live validation ran on a
bare cloud-init VPS with no shim on PATH, which is why this never surfaced.

Found live on the deployed control box (phase 3 verify, e2b box).

Claude-Session: https://claude.ai/code/session_01QnaMFuHVZUdTq5VzxiSg2L
… git-rpc guard

Promote listStatuses() to the Store interface (MemoryStore via a new
BoxStatusStore.list(), RemoteStore via RPC, WriteThroughStore forwards; covered
across every backend by the store-conformance suite). Mount /admin/store as a
shared dispatcher (store-rpc-routes.ts) in both server.ts and core/handler.ts so
a PC's RemoteStore works against the real control box, not only the Vercel plane.
Carry BoxRegistration.sandboxId. Add DELETE /remote/boxes/:id — reap a box's
control-box state (forget + deleteStatus + custody boxes/<sandboxId>/ cleanup).
Reject host-side git.push/git.fetch against a worker-created box whose
hostMainRepo is gone (hostRepoUnavailableReason) instead of a cryptic git -C
failure. Expose custody on the relay handle for the hub reap.

Claude-Session: https://claude.ai/code/session_01C5GMhU6hbgqYRqoZZCCoau
…sandboxId

Centralize the per-box ssh-dir namespace rule in boxSshDirForProvider
(sandbox-core), shared by the hub-worker mirror, the PC push-up, and hub pull —
killing the phase-3 bug where hetzner's un-namespaced dir was read with a
namespace and mirrored nothing. Carry sandboxId through plane-register (create +
resume). A PC-created control-plane cloud box pushes its minted SSH key up to
custody at create (pushBoxSshToCustody; no-op for e2b/vercel).

Claude-Session: https://claude.ai/code/session_01C5GMhU6hbgqYRqoZZCCoau
mirrorBoxSshToCustody now uses boxSshDirForProvider and keys custody by
sandboxId. hub-backend.destroy falls back to reaping a Store-only box
(registration + status + custody) when it isn't in host state. HubState gains
controlPlane (from relay.controlPlaneUrl; null on the control box itself) and the
topbar links to it.

Claude-Session: https://claude.ai/code/session_01C5GMhU6hbgqYRqoZZCCoau
New control-plane subcommands over the admin API: boxes list|rm, prompts
list|answer, credentials pull, custody rm — plus `agentbox hub pull <box>` to
download a hub-created box's SSH keys into ~/.agentbox/boxes/<sandboxId>/ssh so
attach/cp/port-forward work. `agentbox relay status` gains a control-box section
(URL + /healthz reachability). A ControlPlaneAdminClient wraps RemoteStore +
prompt/reap fetches. Tests (temp HOME): hub-pull planner, admin client.

Claude-Session: https://claude.ai/code/session_01C5GMhU6hbgqYRqoZZCCoau
Plan: phase-4 implementation plan + what-shipped + smoke commands, ticked items,
extended Backlog. Public docs: control-plane.mdx "Operate through the control
box" section (boxes/prompts/hub pull/credentials), hub.mdx hub pull, host-relay.md
store/custody/phase-4 notes.

Claude-Session: https://claude.ai/code/session_01C5GMhU6hbgqYRqoZZCCoau
…nt projects volume

The hub queue (web-UI creates, queued agent runs, host-app launchers) spawns
'node $AGENTBOX_CLI_ENTRY …', but the control-box image never built apps/cli
and set no entry — a web-UI create failed instantly with 'cannot spawn queue
worker'. Build the CLI in the image and set AGENTBOX_CLI_ENTRY. Also mount a
persistent /root/projects volume: the full-host hub keeps real project clones
(the worker's per-job temp checkouts are deleted after create and make broken
'projects' in the UI — backlog).

Found live on the deployed control box (final goal-scenario verify).

Claude-Session: https://claude.ai/code/session_01QnaMFuHVZUdTq5VzxiSg2L
…tion)

The compose ran the hub on :3000 in-container, but every CLI relay client
(ensureRelay + the queue's spawned workers) is hardwired to 127.0.0.1:8787 —
so a web-UI create spawned a SECOND relay daemon inside the container that
took ownership of the new box, invisible to the hub's store/registry. Run the
hub on 8787 in-container (compose env + port map, Caddy upstream, EXPOSE) so
in-container CLI spawns discover and REUSE the hub's own relay.

Found live on the deployed control box (final goal-scenario verify).

Claude-Session: https://claude.ai/code/session_01QnaMFuHVZUdTq5VzxiSg2L
All four phases live-verified against the deployed Hetzner control box: web-UI
box create + host-off push (PC relay hard-killed, branch advanced on GitHub via
control-box lease). Records fixes #225/#226 and four new backlog findings
(notably the web-UI/queue create path not wiring git leasing).

Claude-Session: https://claude.ai/code/session_01QnaMFuHVZUdTq5VzxiSg2L
Task-oriented page for the deployed hub / control box: deploy to Hetzner,
create boxes from the web UI or 'create --via-hub', run them with the laptop
off (leased push), and manage them from the PC (boxes list, hub pull, prompts,
custody). Linked from run-an-agent's next steps and added to the Guides nav.
Marked experimental (the control-plane commands are still WIP/hidden).

Claude-Session: https://claude.ai/code/session_01QnaMFuHVZUdTq5VzxiSg2L
…p spinner on deploy failure

A transient Hetzner 502/504 during firewall creation aborted the whole deploy
(and box create) on its first step, because createFirewall used
retryOnAmbiguous: false. Unlike a server/image create (billable — a hidden
success on retry would duplicate a paid resource), a firewall is free and a
retry-duplicate is a harmless, per-op-uniquely-named orphan, so retrying a
transient gateway error is safe. Flip both createFirewall call sites
(control-plane deploy + per-box) to retryOnAmbiguous: true; setFirewallRules /
deleteFirewall already were. Regression test: createPerBoxFirewall recovers
from a 502.

Also stop the 'control-plane setup' deploy spinner (code 1) on failure — it was
declared inside the try, so the catch couldn't stop it and it kept animating
its last 'creating the firewall…' frame under the error message.

Reported live: 'agentbox control-plane setup' → 'deploy to hetzner failed:
hetzner 502 http_502: Bad Gateway' with the spinner still spinning.

Claude-Session: https://claude.ai/code/session_01QnaMFuHVZUdTq5VzxiSg2L
…r secrets

The deploy's PROVIDER_SECRET_KEYS (what it scp's from the host secrets.env to
the VPS) drifted from apps/hub's PROVIDER_CRED_KEYS (what marks a provider
'configured'): it only carried hcloud/e2b/daytona, so Vercel and DigitalOcean
showed 'not configured' on a freshly deployed control box even with the creds
set locally. Add DIGITALOCEAN_TOKEN/_API_URL and the Vercel access-token keys
(VERCEL_TOKEN/OIDC/TEAM_ID/PROJECT_ID). VERCEL_AUTH_SOURCE is intentionally
excluded — a CLI-login token lives in the Vercel CLI store, not secrets.env, so
copying the marker without a token would falsely show 'configured' and fail at
create time; use a VERCEL_TOKEN or the hub Settings form for vercel-from-hub.

Reported live: vercel + digitalocean missing on a newly deployed control box.

Claude-Session: https://claude.ai/code/session_01QnaMFuHVZUdTq5VzxiSg2L
madarco added 2 commits July 25, 2026 19:11
A daytona create failed with "failed to start the claude session (exit
255)" and no cause: the box came up healthy with no agent in it. Two
bugs behind that.

`runDetached` resolved on the child's `exit` event, which fires before
the piped stderr has drained — an ssh that gives up in under a second
reported an empty stderr, so the thrown error carried nothing but the
exit code. Resolve on `close` instead.

And the pre-start fired exactly one ssh. Daytona's SSH gateway takes the
ephemeral attach token as the username and hangs up on one it doesn't
recognise yet, which is what a token minted a second after sandbox
create looks like. Retry a 255 (ssh's own pre-command failure) up to 3
times with backoff — each attempt re-runs buildAttach and so mints a
fresh token. A non-transport exit still fails on the first try, and a
session the dropped connection left behind is accepted rather than
retried into tmux's duplicate-session error.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp
@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agentbox-web Ready Ready Preview, Comment Jul 26, 2026 6:08pm

Request Review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 4 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d1a422f. Configure here.

const cfg = await loadEffectiveConfig(process.cwd());
const url = (cfg.effective.relay.controlPlaneUrl ?? '').replace(/\/$/, '');
const adminToken = process.env.AGENTBOX_RELAY_ADMIN_TOKEN ?? '';
if (!url || !adminToken) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Credential push skips env file

Medium Severity

After credentials propagate, the new custody mirror reads AGENTBOX_RELAY_ADMIN_TOKEN only from the environment and never loads ~/.agentbox/control-plane/control-plane.env. On a typical machine where setup wrote the token to that file, the push is a silent no-op even though hub custody is configured.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d1a422f. Configure here.

log.warn(
`control box configured but ${routing.fellBackReason}; building ${providerName} box locally.`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Via-hub flag falls back silently

Medium Severity

With --via-hub, routing returns hub immediately, but if createCloudBoxViaHubAndAdopt returns null (e.g. missing git origin), foreground cloud runs continue into a local cloudAgentCreate without failing or warning—unlike agentbox create --via-hub, which stops with an error.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d1a422f. Configure here.

Comment thread apps/cli/src/box-ref.ts
// The box may well exist on the control box — don't let the error imply it
// definitely doesn't.
log.warn(`could not reach the control box — did not check whether '${ref}' is one of its boxes.`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Numeric ref shows index error

Low Severity

After hub auto-adopt misses, purely numeric refs still exit with “no box with index … in this project” even when the user meant a numeric cloud sandbox id. Hub adoption was intentionally tried first, but the failure path still assumes a per-project index.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d1a422f. Configure here.

if (box.needsAdopt !== true) return box;
const adopted = await tryAutoAdopt(box.name, process.cwd());
if (adopted === null || adopted === 'unreachable') return null;
return (await listBoxes()).find((b) => b.id === adopted.id) ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dashboard adopt uses name only

Low Severity

When a hub-only row needs adoption, the dashboard calls tryAutoAdopt(box.name) instead of the stable box.id (or sandbox id). Registrations allow optional names, so adoption can fail while the same box would resolve by id via hub adopt.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d1a422f. Configure here.

It sat under Getting started, where a pre-release channel is the wrong
thing to hand a new user. Both inbound links come from Reference pages
(cli, configuration), so that is where readers arrive from.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp
madarco added 2 commits July 25, 2026 20:20
`hub setup` / `hub deploy hetzner` sent every progress line to a @Clack
spinner, which overwrites itself, and only wrote `deploy.json` on success —
so a deploy that died after the VPS booted (e.g. a 502 at `/healthz`) left
no log and no record of the still-running machine.

- Tee both commands to `~/.agentbox/logs/hub-{setup,deploy}.log`.
- Add `onProvisioned` to `deployControlPlaneToHetzner`, fired as soon as the
  VPS has an IP, so the record is persisted before the ssh wait, the compose
  build, and the healthz poll — and print ssh + `docker compose logs app`
  recovery steps on failure.
- Emit a `Host agentbox-hub` block in the managed `~/.agentbox/ssh/config`
  from that record, so the control box is reachable with `ssh agentbox-hub`
  instead of a per-deploy key path under `control-plane/ssh/<stamp>/`.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp
…m port

`DEFAULT_DEPLOY_REF` was hardcoded to `main`, but the deploy is a two-sided
contract: the VPS builds `apps/hub` from the cloned ref while the HOST writes
the Caddyfile and `.env` that wrap it. A nightly CLI wrote
`reverse_proxy app:8787` and full-hub env keys, while the VPS built v0.27.1 —
whose compose publishes `8787:3000` behind Postgres and whose container listens
on :3000. Caddy dialled a dead port for the whole healthz window while
`curl localhost:8787/healthz` on the VPS answered fine, surfacing only as
"did not become healthy (HTTP 502)".

- `deployRefForVersion` derives the ref from the running CLI: `nightly` for a
  nightly build, `v<version>` for a release.
- Parse the Caddy upstream port from the deployed compose (`hubContainerPort`)
  instead of hardcoding this CLI's port.
- Reject a pre-full-hub ref (`isFullHubCompose`) right after the clone rather
  than after a 20-minute build.
- On a failed healthz poll, ssh in and report whether the hub is up and
  unreachable or actually down, with `docker compose ps` + app logs.
- The cloud-init clone no longer falls back to a bare `git clone`, which
  silently left the default branch checked out — a SHA ref now checks out, and
  a bad ref fails loudly instead of building the wrong version.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp
madarco added 4 commits July 26, 2026 01:05
… test

`user-config.schema.json` is `additionalProperties: false`, so a key the parser
accepts but the schema doesn't know is a red squiggle in the user's editor. 15
registered keys had drifted out of it, including four whole branches (`git`,
`ssh`, `cloud`, `integrations`) — so documented settings like `git.pushMode`,
`relay.controlPlaneUrl`, `cloud.viaHub` and `update.channel` all flagged as
invalid.

`schema-drift.test.ts` missed it because it only validates a curated fixture
list. Add an exhaustive KEY_REGISTRY -> schema check plus a per-enum value
comparison; the enum check immediately caught `box.provider` missing
`remote-docker`.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp
Installing a GitHub App is an owner/admin decision, so a control box was
unusable on any repo the user doesn't own — hit live: the App was created under
one account, the repos live under another, and every cloud create failed with
"GitHub App is not installed on <repo>" no matter what was clicked.

The fix is not a new credential model. The relay's bundle path — box asks the
host to push, box never sees a credential — is already wired and reachable on a
control box (server.ts routes git.push/git.fetch from any cloud registration
into executeCloudAction). It just had no host checkout, no credential and no gh.

- `hub.gitAuth: gh | app` (default `gh`). `hub setup` now reuses the token from
  your own `gh auth login` (or git's credential helper), shows whose it is and
  what it can do, and skips the App flow entirely. `--git-auth app` keeps it.
- runGitRpc falls back to a scratch repo when there is no host checkout: a
  bundle is self-contained, so unbundle + push needs no persistent clone. The
  push target comes from the REGISTERED origin, never the box's own — a box
  could rewrite `origin` and redirect the host's credentials.
- `auto` push mode leases only when the hub runs an App; a gh-mode hub holds one
  broad token that must never enter a box, so boxes route through the relay.
- The hub loads the token at boot and runs `gh auth setup-git`, so clone, push
  and the gh.pr.* host actions all authenticate from one place. `gh` added to
  the image (those actions exited 127 before).
- The worker no longer throws at construction without an App — that killed the
  hub at boot, which in gh mode is the normal day-one state. Resolved per job.
- The token ships in the VPS data volume, not compose env (docker inspect).
- The "run `agentbox hub add`" nag is silent in gh mode: there is no App.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp
The hub's git token must not survive into the compose env — that is readable
via `docker inspect`. Pin the split, the export/whitespace forms, the App-mode
no-op, and that an empty assignment is not shipped as a credential.

Claude-Session: https://claude.ai/code/session_01WM35PAMmswyzcxmAbMXaPp
* test: add the always-on hub test VM (clean PC for remote-hub testing)

scripts/hub-test-vm.sh provisions a persistent clean Ubuntu VPS that plays a
user's PC: published CLI from npm, gh logged in as the test account, and a
credential vault outside ~/.agentbox so every run can start from virgin state.
docs/hub-test-vm.md is the runbook + test matrix.

* test(hub-test-vm): fix reset self-kill + firewall reap, record the first live pass

- pkill -f agentbox-relay matched the ssh command line running it, so reset
  killed its own session and stopped before the wipe; bracket the pattern.
- Retry the control box's firewall delete (it stays attached to the dying
  server) and treat 404 as gone.
- run/log subcommands: a long command needs tmux, an ssh nohup dies mid-pull.
madarco added 10 commits July 26, 2026 09:41
…or source

`agentbox hub deploy hetzner` cloned the monorepo onto the VPS and built 14
workspace packages plus the CLI there — ~2m40s of a ~4m45s deploy — to produce
an artifact the published package already carries: `runtime/hub/apps/hub/server.js`,
the same standalone bundle `agentbox hub` spawns locally.

It now installs `@madarco/agentbox` pinned to the deploying CLI's exact version.
The host ships the compose stack (the base docker-compose.yml verbatim, plus an
override that swaps only the build block), so there is no repo on the VPS at all
and no version-skew surface — that coupling is what made a nightly CLI's deploy
502 against a `main` hub listening on a different port.

It also fixes base-image fingerprint parity by construction: the control box's
runtime/ tree is now the same npm tarball the PC installed, rather than a ref
that happens to match.

`--ref`/`--repo` keep the clone-and-build path for deploying unreleased code, and
a dev build (whose version was never published) falls back to it automatically.
`--package <spec>` pins a different npm spec. deploy.json now records which build
a control box is running, and `hub status` shows it.
… port

A hub answering on the VPS but not over HTTPS was reported as one guess —
"Caddy cannot reach it, so its upstream port does not match" — which is wrong
whenever the certificate is the problem. Hit live: sslip.io derives the hostname
from the IP, so a recycled Hetzner address arrived already at Let's Encrypt's
5-certs-per-identifier-per-week cap, and the deploy blamed a port that was
perfectly correct.

diagnoseUnhealthy now greps Caddy's own log when the hub is healthy locally, and
names the actual cause — including the fix for the rate-limited case (redeploy
for a different IP, or pass --domain).
…ntrol

The sslip.io default derives the hostname from the IP, so it isn't really yours
and Hetzner reuses released addresses — landing on one that already had five
certificates issued this week leaves a healthy hub with no usable HTTPS until
the 7-day window rolls. --domain is the way out (and what the new rate-limit
diagnostic points at); the underlying deploy already accepted a domain, only the
CLI never exposed it.
MDX parses the attribute value as JSX, so \" is an invalid attribute name
character, not an escaped quote — `next build` failed to evaluate the page.
… text

Bugbot caught that the certificate check matched Caddy's SUCCESS lines:
`tls.obtain` appears in "obtaining certificate", "lock acquired", and
"certificate obtained successfully", so a healthy cert plus a genuine upstream
mismatch would have been reported as a certificate problem.

The verdict now comes from an HTTPS request made ON the VPS (--resolve pinned to
loopback), which exercises the real certificate and the real reverse_proxy
upstream: 200 means the break is outside the box (DNS / :443 rule), 000 means the
TLS handshake never completed, any other status means Caddy answered and the
upstream is wrong. The log is read only for the rate-limit detail, which has an
unambiguous error signature.

Split out as the pure `describeCaddyHop` so the three-way verdict is unit-tested,
including the exact log that fooled the old check.
… build

A control box was a one-way trip: the only way to change its version was to
deploy a whole new VPS, and the only way to get rid of one was to delete the
server by hand and then remember to clear ~/.agentbox/control-plane, the
agentbox-hub SSH block, and relay.controlPlaneUrl.

`hub update` moves an existing box to a new build in place. `--channel
nightly|stable` installs the newest published build; the default matches this
CLI's exact version, which is what keeps shared bake fingerprints matching.
`--ref` switches a box to building from source. Everything after the ssh hop is
the same code a deploy runs (the new applyControlPlaneConfig), so an update
re-ships the compose stack, regenerates .env and the Caddyfile, and re-pushes
provider secrets — a newer hub can want new env keys or a different container
port, and re-running the whole configuration is the only way an update stays
equivalent to a redeploy. The data volume is untouched, so the store, logins and
custody survive.

It re-syncs the firewall's SSH source over the Hetzner API before connecting:
only :22 is IP-locked (:80/:443 stay open so boxes can reach the hub), so a
laptop that changed networks otherwise cannot ssh in at all — and the API path
still works when ssh doesn't.

`hub destroy` tears down the server + firewall and purges the local state.
It first asks the hub what boxes it still owns and refuses without --force,
because cloud boxes outlive the hub and its store is often their only record.
Teardown needs no ssh, and never throws for an already-gone resource — a
half-deleted box must not leave config pointing at nothing with no way to clear
it. `unset-url --purge` now shares that purge, so it stops leaving a stale
`Host agentbox-hub` block behind.

`hub status` gains version/channel/drift. The relay already reported
AGENTBOX_CLI_VERSION on /healthz; both hub images now export it, read from the
installed package's manifest rather than the requested spec (a spec can be a
dist-tag). The live version beats the deploy record, which only says what was
last deployed — wrong after a failed update, and absent for a hub someone else
set up.
Found live. Downgrading a control box to a build that cannot run with its config
reported SUCCESS: the hub starts listening BEFORE it starts its create worker, so
a version that boots and then throws serves healthz for a moment and dies, and
the poll landed in that window. The box then restarted every 60s while the CLI
said "Control box is healthy".

applyControlPlaneConfig now checks `docker compose ps` after the healthz poll —
a crash-looping container reports `restarting`, never `running` — and fails with
the app logs attached.

Also from the same run: `hub update --ref` against a package-mode box died with a
bare "not a git repository", because cloud-init never cloned one there. It now
says so and points at `hub deploy hetzner --ref` instead.

And the destroy safety gate is split out as the pure `destroyGate` so its rules
are testable without a live hub: a non-empty box list is a hard stop, but an
unreachable hub is NOT — a deploy that never came up is the most common thing to
want deleted, and refusing there would leave no way to clean up at all.
…at dist-tags

Three fixes, from CI and the bugbot pass:

- destroy built the Hetzner client eagerly, so a record with neither id — which
  deletes nothing — still threw "HCLOUD_TOKEN is empty". Only my machine had a
  token, so it passed locally and broke CI. The client is now lazy.

- `hub update` printed "Control box is healthy" from a probe it never checked.
  The update already waits for a healthy hub, so a failing probe here is usually
  transient, but claiming health from a failed probe is the same false success
  the crash-loop guard was just added to prevent.

- `--package nightly` records the literal dist-tag, and reading it as a version
  printed `version: nightly` and classified it `stable` (channelOfVersion only
  looks for a `-nightly.` suffix). A spec that isn't a concrete version now
  reports no version and no channel until the hub says what it installed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant