Skip to content

feat(hub): hub update and hub destroy, plus the running build in hub status#261

Merged
madarco merged 4 commits into
nightlyfrom
feat/hub-lifecycle-commands
Jul 26, 2026
Merged

feat(hub): hub update and hub destroy, plus the running build in hub status#261
madarco merged 4 commits into
nightlyfrom
feat/hub-lifecycle-commands

Conversation

@madarco

@madarco madarco commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Why

A control box was a one-way trip. Changing its version meant deploying a whole new VPS; getting rid of one meant deleting the server by hand in the Hetzner console and then remembering to clear ~/.agentbox/control-plane, the agentbox-hub SSH block, and relay.controlPlaneUrl. And hub status couldn't say what version was actually running — which matters now that host↔hub version parity is what makes shared bake fingerprints match.

The enabling refactor

deployControlPlaneToHetzner split into provision + applyControlPlaneConfig. Everything from "read the compose" to the healthz poll is exactly what an update has to redo — a newer hub can want new .env keys, a different container port, or new compose assets — so hub update re-runs the deploy's own configuration half rather than growing a second implementation of it. No behaviour change for deploy.

agentbox hub update

Moves the existing VPS to a new build in place; the data volume (store, logins, custody, box SSH keys) is untouched.

agentbox hub update                     # match this CLI's version (default)
agentbox hub update --channel nightly   # newest published nightly | stable
agentbox hub update --package 0.28.0    # an exact version
agentbox hub update --ref my-branch     # switch to building from source

Defaulting to this CLI's version is deliberate — that parity is what keeps bake records shared.

It re-syncs the firewall's SSH source over the Hetzner API before it connects. Only :22 is IP-locked (:80/:443 stay open so boxes reach the hub from anywhere), so a laptop that changed networks can't ssh in at all — and the API path still works when ssh doesn't, so this repairs a box that has already locked you out. Regenerating the env also refreshes AGENTBOX_HUB_ADMIN_CIDR, so direct PC→box SSH keeps working after your IP moves.

agentbox hub destroy

Prints exactly what it will delete — VPS, firewall, ~/.agentbox/control-plane, the agentbox-hub alias, relay.controlPlaneUrl — and confirms first. --keep-credentials keeps control-plane.env for a later redeploy.

Two deliberate behaviours:

  • It asks the hub what boxes it still owns and refuses without --force. Cloud boxes outlive the hub and its store is often their only record. An unreachable hub says so rather than blocking, since a broken deploy is exactly what you'd want to delete.
  • It never throws for an already-gone resource, so the local purge always runs. A half-deleted control box must not leave config pointing at nothing with no command that clears it.

Teardown needs no ssh at all, so it works from a different network than the one that deployed the box. unset-url --purge now shares the same purge helper, which also fixes it leaving a stale Host agentbox-hub block behind.

hub status

hub: remote (reachable)
  url:     https://<ip>.sslip.io
  health:  0 box(es), 0 event(s)
  version: 0.28.0-nightly.202607260716
  channel: nightly
  build:   @madarco/[email protected] (npm)

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 like nightly). The live version beats the deploy record — the record only says what was last deployed, which is wrong after a failed update and absent for a hub someone else set up. channel is source (<ref>) for a build-from-source box, since the ref is the distinction that matters there. A drift nudge appears only when there's a live reading to compare, so a stale record can't nag forever.

Verification

Unit: 8 tests for the status precedence (live beats record, fallback, source-ref channel, drift only when live) and 4 for destroy tolerance (already-gone server still deletes the firewall, ordering, never throws, no-op). Dockerfile drift guards assert the version comes from package.json and not from AGENTBOX_SPEC. Full pnpm build / typecheck / lint / suites green, plus the web build.

Live on Hetzner — deploy → status → update --package <older>update back → update --refdestroy (with and without a registered box). Results in a follow-up comment.


Note

Medium Risk
Changes touch production control-plane provisioning, firewall rules, and destructive teardown; mistakes could orphan cloud boxes or leave broken local config, though destroy gating and non-throwing partial teardown reduce that risk.

Overview
Closes the Hetzner control box lifecycle: agentbox hub update upgrades the existing VPS in place (data volume kept) via the same configuration path as deploy, and agentbox hub destroy removes server, firewall, local deploy state, SSH alias, and relay.controlPlaneUrl, with guards against orphaning registered boxes unless --force.

The enabling change in @agentbox/sandbox-hetzner extracts applyControlPlaneConfig from deploy so update re-ships compose, env, Caddy, secrets, and docker compose up. Updates can re-sync firewall SSH to the current egress IP over the API before SSH, and post-health checks use docker compose ps so a one-off healthy /healthz during a crash loop is not treated as success.

hub status for remote hubs now shows version (live /healthz over deploy record), channel, build line, and a drift nudge toward hub update only when a live version differs from the CLI. Hub Dockerfiles set AGENTBOX_CLI_VERSION from the installed package.json, not the npm spec/dist-tag.

unset-url --purge uses purgeLocalControlPlaneState so stale agentbox-hub SSH config is cleared. Docs and unit tests cover destroy gating, status precedence, and destroy tolerance for already-deleted cloud resources.

Reviewed by Cursor Bugbot for commit 4ba6053. Configure here.

madarco added 2 commits July 26, 2026 18:13
… 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.
@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
agentbox-web Skipped Skipped Jul 26, 2026 6:03pm

Request Review

Comment thread apps/cli/src/commands/control-plane.ts Outdated
Comment thread packages/sandbox-hetzner/src/control-plane-deploy.ts
Comment thread apps/cli/src/commands/hub.ts
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.
@madarco

madarco commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

Live verification (Hetzner, one VPS)

Step Result
hub deploy hetzner healthy; hub status shows version: 0.28.0-nightly.202607260716, channel: nightly, no drift nudge
hub update --package <older nightly> version really changed on the same server id — proves the healthz version is live, not echoed from deploy.json
hub update (no flags) recovered the box in place; success line reports running 0.28.0-nightly.202607260716
hub update --ref nightly on a package box refused with an actionable message, box untouched
hub destroy prompt listed all five items; server + firewall deleted (firewall retry fired once)

Post-destroy, all six claims verified independently: server gone, firewall gone, ~/.agentbox/control-plane gone, agentbox-hub SSH block gone, relay.controlPlaneUrl unset, and hub status correctly falls back to the local hub.

Two bugs the live run caught (both fixed in 0a87bd4)

1. A crash-looping update reported success. The first downgrade printed "Control box is healthy" while the container restarted every 60 seconds. The hub starts listening before it starts its create worker, so a build that boots and then throws serves healthz for a moment and dies — and the poll landed in that window. Now applyControlPlaneConfig checks docker compose ps after the poll (a crash-looping container reports restarting, never running) and fails with the app logs attached. Re-running the same downgrade afterwards correctly failed.

2. hub update --ref on a package-mode box died with a bare "not a git repository" — cloud-init never clones one there. It now explains that and points at hub deploy hetzner --ref.

A note on why that downgrade crashes

Not a defect in this PR: 0.28.0-nightly.202607251935 predates the boot-throw fix from the hub.gitAuth work, so an older hub cannot start against a gh-mode env (no GitHub App configured). It is a genuine example of what hub update should refuse to call a success — which is exactly what it now does.

Coverage

The destroy safety gate is split out as the pure destroyGate and unit-tested (refuses on a non-empty box list; allows with --force while naming the orphans; never blocks on an unreachable hub) rather than spending a live cloud box on it. 121 hetzner + 991 CLI tests green.

@madarco

madarco commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

}): Promise<ControlPlaneDestroyResult> {
if (readHetznerCredStatus().source === 'none') {
throw new Error('no HCLOUD_TOKEN configured — run `agentbox hetzner login` first');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Destroy skips purge without token

Medium Severity

hub destroy calls runHetznerDestroy, which throws when no HCLOUD_TOKEN is configured. The handler never reaches purgeLocalControlPlaneState or clearing relay.controlPlaneUrl, so local state can stay pinned to a hub the user already removed in the console.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0a87bd4. Configure here.

…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.
@madarco

madarco commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

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

✅ Bugbot reviewed your changes and found no new issues!

1 issue from previous review remains unresolved.

Fix All in Cursor

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 4ba6053. Configure here.

@madarco
madarco merged commit 4ba6053 into nightly Jul 26, 2026
5 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant