Hetzner Cloud Provider
July 10, 2026 Β· View on GitHub
Note: the filename uses the user-requested spelling
hertzner(extra t); the actual product is Hetzner Cloud. Provider name and package usehetzner.
The plan lives at ~/.claude/plans/implement-a-sandbox-hertzner-using-serialized-pony.md. This file tracks what's done, in progress, and deferred for the Hetzner provider build-out. Mirrors docs/daytona-backlog.md in structure.
Status legend:
- β¬ pending
- π¦ in progress
- β done
- βοΈ deferred (with reason)
Already landed
agentbox prepare --provider hetzner end-to-end (mints temp VPS, runs install-box.sh, snapshots, cleans up) Β· agentbox hetzner login + login --status (interactive HCLOUD_TOKEN setup, persists to ~/.agentbox/secrets.env) Β· agentbox hetzner firewall sync <box> [--source <cidr>] (re-detects egress IP and updates the per-box Hetzner firewall, no VPS reboot) Β· agentbox hetzner firewall show <box> (diagnostic β prints current rules + current host egress IP for drift comparison) Β· @agentbox/sandbox-hetzner package fully wired into the CLI's lazy-provider registry (--provider hetzner resolves via getProvider) Β· CloudBackend for hetzner with the full surface (provision/get/list/start/stop/pause/resume/destroy/state/exec/uploadFile/downloadFile/listFiles/previewUrl/signedPreviewUrl/attachArgv/createSnapshot/deleteSnapshot) Β· SshTunnelManager (per-box ControlMaster + dynamic -L port forwards via ssh -O forward) Β· auto-locked Hetzner Cloud Firewall (egress-IP-only inbound SSH, multi-probe fail-loud detection) Β· ed25519 per-box SSH keys minted into ~/.agentbox/hetzner/boxes/<sandboxId>/ssh/ with accept-new known_hosts and dropped on destroy Β· install-box.sh idempotent shell mirror of Dockerfile.box (Node 24, Python, Docker + agentbox-dockerd-start, VNC stack, agent-browser + Playwright Chromium, Claude/Codex/OpenCode agents, agentbox-ctl bundle, baked config files, sshd hardening drop-in) Β· runtime asset staging at apps/cli/runtime/hetzner/ via scripts/stage-runtime.mjs Β· withHetznerRetry (mirrors daytona retry classification: 429/locked/conflict always, 5xx ambiguous, 4xx never) Β· hand-rolled REST client with typed HetznerApiError and paginated list endpoints Β· 41 unit tests across retry, egress-ip, firewall, env-loader, cloud-init, runtime-assets, backend state mapping Β· DinD inside the VPS via the unchanged launchCloudDockerdDaemon scaffolding (the install script bakes the same /usr/local/bin/agentbox-dockerd-start the docker provider ships) Β· --provider hetzner validated in agentbox prepare's help text and box.provider config enum.
Phases
Phase 1 β Shared scaffolding β
- β
Lifted the Portless helpers' shared shape: kept them in
packages/sandbox-docker/src/portless.ts(the dep direction issandbox-cloud β sandbox-docker, so moving them intosandbox-cloudwould create a cycle) and re-exported the names from@agentbox/sandbox-cloudso non-docker providers (hetzner) consume them via the cloud package without taking a direct sandbox-docker dep. Matches the existingstage*re-export pattern. - β
Parameterized
portlessBrowserEnv(boxName, { mapTarget })βhost.docker.internalfor Docker (passed atpackages/sandbox-docker/src/create.ts~line 589),127.0.0.1for Hetzner. Test updated. - β
Added
box.defaultCheckpointHetznerto@agentbox/config:UserConfig+EffectiveConfig+BUILT_IN_DEFAULTS+KEY_REGISTRYentry, plus the'hetzner'branches inresolveDefaultCheckpointanddefaultCheckpointConfigKey.apps/cli/src/commands/checkpoint.tssweep-clear extended. - β
Added
'hetzner'toKnownProviderNameinapps/cli/src/provider/registry.tsand thegetProviderswitch. Gate callsensureHetznerCredentials()only β the base-snapshot gate lives inbackend.provisionto avoid chicken-and-egg withagentbox prepare --provider hetzner. - β
Added
'hetzner'case toresolveCloudBackendinpackages/relay/src/host-actions.ts(with the same MODULE_NOT_FOUND friendly-error wrapper as daytona). - β
ProviderName(core) +ProviderKind(config) gained'hetzner'.
Phase 2 β @agentbox/sandbox-hetzner package skeleton β
- β
New package
packages/sandbox-hetzner/withpackage.json(deps:@agentbox/{config,core,sandbox-cloud,sandbox-core},@clack/prompts,commander,execa),tsconfig.json,tsup.config.ts(two-entry:./provider +./clifor commander),src/index.ts(full public-surface re-export). - β
src/client.tsβ hand-rolled REST client. Surface: server create/get/list/delete, action poweron/poweroff/shutdown/create_image, image list/get/delete, firewall create/set_rules/get/delete, locations probe forloginvalidation. TypedHetznerApiErrorcarryingstatusCode+code+details. PaginatedlistServers/listImages. - β
src/retry.tsβwithHetznerRetrywith the same error-classification shape as daytona: 429/locked/conflict always retry, 5xx ambiguous (caller opts in), 4xx never. 9 unit tests cover the classification + the wrapper's retry/exhaustion behavior. - β
src/credentials.tsβensureHetznerCredentials()mirror of daytona's. PersistsHCLOUD_TOKENto~/.agentbox/secrets.env(chmod 600, atomic rename, strips duplicates). Interactive on TTY, silent skip non-TTY.readHetznerCredStatus()+maskKey()forlogin --status. - β
src/env-loader.tsβ loadsHCLOUD_TOKEN/HCLOUD_ENDPOINTfrom~/.agentbox/secrets.envon first use. 6 unit tests onparseEnvFile. - β
src/egress-ip.tsβ multi-probe (api.ipify.org β ifconfig.io β icanhazip.com), 3s per probe, fails loud if all fail (no silent 0.0.0.0/0). 3 unit tests cover happy path, fall-through, fail-loud. - β
src/firewall.tsβcreatePerBoxFirewall(client, {name, sourceCidr, labels}),syncFirewallSource(client, id, cidr),deletePerBoxFirewall(client, id)(idempotent on 404).normalizeSourceCidr(bare-IP β /32 or /128 β unchanged) +sshOnlyInboundRule(cidr)helpers. 5 unit tests. - β
src/prepared-state.tsβ JSON state at~/.agentbox/hetzner-prepared.jsonwith{base}. Read/write/update helpers. Atomic rename + chmod 600. (An earlyprojects[<projectHash>]field was removed β never wired; the per-project tier is checkpoint +set-default, see Phase 5.) - β
src/cli.tsβagentbox hetzner login(functional),hetzner login --status(functional).
Phase 3 β Base snapshot (agentbox prepare --provider hetzner) β
- β
packages/sandbox-hetzner/scripts/install-box.shβ idempotent install script run on a fresh Ubuntu 24.04 VPS. Mirrorspackages/sandbox-docker/Dockerfile.box:- Node 24 (NodeSource), Python 3, corepack@latest + pnpm/yarn shims.
- docker.io + iptables + fuse3 + fuse-overlayfs;
agentbox-dockerd-startbaked at/usr/local/bin/; vscode in thedockergroup; systemd'sdocker.service/docker.socketdisabled (agentbox helper drives dockerd). - tigervnc-standalone-server + tigervnc-common + tigervnc-tools, novnc, websockify, autocutsel, xclip +
/usr/local/bin/agentbox-vnc-start. - Playwright Chromium downloaded as
vscode(so cache lands in vscode's home), stable symlink at/usr/local/bin/chromium; agent-browser + portless CLI as globals. - Claude Code via native installer (Anthropic-canonical path) by default;
box.claudeInstall=npm(orprepare --claude-install npm) swaps in@anthropic-ai/claude-codeand symlinks it into~/.local/bin/claudeβ an opt-in escape hatch for the intermittent Cloudflare 403 the native CDN returns to Hetzner egress IPs. The install mode is folded into the base-snapshot fingerprint (viaclaudeInstallFingerprint), so switching modes re-bakes. Generic across all cloud/bake providers (hetzner/vercel/e2b/daytona/docker). Codex CLI (@openai/codex) + bubblewrap, OpenCode CLI (opencode-ai). agentbox-ctlfrom/tmp/agentbox-ctl(shipped via scp) β/usr/local/bin/.- Baked config files (scp'd from host):
/etc/profile.d/agentbox.sh(PATH prepend + COLORTERM + DISABLE_AUTOUPDATER + LANG + DISPLAY + AGENT_BROWSER_EXECUTABLE_PATH + BROWSER),/etc/tmux.conf(verbatim from Dockerfile.box),/etc/claude-code/CLAUDE.md,/etc/claude-code/managed-settings.json,/usr/local/share/agentbox/codex-hooks.json,/usr/local/share/agentbox/setup-guide.md. - User
vscode(UID 1000) created (renames any pre-existing UID-1000 user from Hetzner's stock image tovscodeto preserve cloud-init authorized_keys); sudoersNOPASSWD: ALLdrop-in; credential pivot symlinks under~/.agentbox-creds/{claude,codex,opencode}/. - sshd hardening drop-in at
/etc/ssh/sshd_config.d/agentbox.conf:PasswordAuthentication no,PermitRootLogin no,AllowUsers vscode,AllowTcpForwarding yes,GatewayPorts no,PermitTunnel no,X11Forwarding no. sshd reloaded at end of script. - BEGIN/END markers per major step on stdout so
tail -f ~/.agentbox/logs/prepare.logshows real progress;set -euo pipefailfor fast-fail.
- β
src/cloud-init.tsβgeneratePrepareCloudInit({sshPubkey})(temp VPS, root login) +generateBoxCloudInit({sshPubkey, boxName, boxEnv})(per-box VPS, vscode login +/etc/hostslocalhost alias + optionalbox.env). Hand-rolled YAML emitter (no extra dep). - β
src/ssh-key.tsβmintSshKey(targetDir, comment)(ed25519 viassh-keygen, 0600 private) +mintPrepareKey()(ephemeral under~/.agentbox/hetzner/prepare-<ts>/, cleanup callback). - β
src/ssh-cli.tsβsshExec/scpUpload/scpDownload/waitForSshthin wrappers around systemssh/scpwith theStrictHostKeyChecking=accept-new+ per-keyUserKnownHostsFile+BatchMode+LogLevel=ERRORbaseline. - β
src/poll.tsβ genericpollUntil(label, check, opts)with exponential interval (1s β 2s β β¦ capped at 10s); timeout error names the label so failures are diagnosable. - β
src/runtime-assets.tsβ resolves the 10 on-disk files the install script needs from either<cliRoot>/runtime/hetzner/...(published CLI path, staged byapps/cli/scripts/stage-runtime.mjs) or the monorepo source tree (dev fallback).findStagedCliRuntimeRoot()auto-detects the published-CLI location by inspectingimport.meta.urlβ no caller threading required. - β
src/prepare.tsβprepareHetzner({...}): mint ephemeral SSH key β detect egress IP (or honorfirewallSource) β create per-prepare Hetzner firewall β create temp VPS (cx22/nbg1defaults, configurable) with cloud-init injecting the pubkey β pollwaitForSsh(5min deadline) β scp 10 runtime assets to/tmp/in parallel + chmod βssh root@vps bash /tmp/agentbox-install.sh(30min deadline, install script's stdout teed throughonLogas[install] ...) βclient.createImage(serverId, {type:'snapshot', description, labels})βpollUntilimagestatus==='available'(20min deadline) βwritePreparedStateβ delete VPS + firewall. - β Failure-cleanup discipline: every catchable error path runs cleanup of VPS + firewall (best-effort, surfaces a clear "check Hetzner dashboard manually" warning on a cleanup-failure). The user can never end up with a forgotten β¬4/mo VPS due to a transient prepare error.
- β
Idempotent skip-fast: when
~/.agentbox/hetzner-prepared.jsonalready records a base AND that image still exists on Hetzner AND the install-script SHA256 matches AND--forcewas not passed β skip rebuild, return existing record. - β
apps/cli/scripts/stage-runtime.mjsextended to also stageruntime/hetzner/{scripts/install-box.sh, ctl.cjs, agentbox-vnc-start, agentbox-dockerd-start, agentbox-checkpoint-cleanup, agentbox-open, custom-system-CLAUDE.md, claude-managed-settings.json, agentbox-codex-hooks.json, agentbox-setup-skill.md}. Verified atapps/cli/runtime/hetzner/afterpnpm -w build. - β
ensureHetznerBaseSnapshot()exists and is called bybackend.provision(lifts the Phase-2 placeholder) β throws an actionable error pointing atagentbox prepare --provider hetznerwhen no base snapshot is on file yet. Not called fromgetProvider('hetzner')to avoid chicken-and-egg with prepare itself. - β
Smoke:
agentbox prepare --provider hetzner -y(no credentials) surfaces a clean "HCLOUD_TOKEN is empty / runagentbox hetzner login" error. - β
8 new unit tests for
cloud-init(5) +runtime-assets(3). 31 hetzner-package tests total at end of Phase 3.
Phase 4 β CloudBackend impl + SSH tunnel manager β
- β
src/ssh-tunnel.tsβSshTunnelManagerclass with one ControlMaster per box (~/.agentbox/boxes/<sandboxId>/ssh/control.sock).openspawnsssh -fNT -M;forward(boxId, remotePort)mints (or returns cached)ssh -O forward -L 127.0.0.1:<localPort>:127.0.0.1:<remotePort>;unforward/close/closeAlltear down.isAliveusesssh -O checkto reuse a master from a prior crashed process when the socket is still responsive. - β
src/backend.tsβhetznerBackend: CloudBackendwith every method live:provision: gates onensureHetznerBaseSnapshot, resolves image (base snapshot id / numeric id / snapshot description), detects egress IP, creates per-box firewall, mints per-box ed25519 key into a temp dir (renamed to~/.agentbox/hetzner/boxes/<sandboxId>/after server creation), creates VPS with cloud-init injecting the pubkey, pollswaitForSsh(5min), opens ControlMaster. Failure-cleanup deletes the VPS + firewall + ssh dir.get/list: REST get + paginated list withagentbox.managed=truelabel selector.start/resume/stop/pause/destroy: poweron/shutdown(+fallback poweroff)/delete with action polling.destroyalso tears down the firewall via theagentbox.firewall=<id>label baked into the server's labels at provision, plus the per-box ssh dir.state: Hetzner status βCloudStatemapping (10 cases, including transitional ones reported asrunningso callers don't ping-pong). 10 unit tests verify the mapping.exec/uploadFile/downloadFile/listFiles: all reuse the ControlMaster via-S <sock>.execwraps the remote cmd inbash -lcso/etc/profile.d/agentbox.sh's PATH/DISPLAY/AGENT_BROWSER_* are sourced.previewUrl/signedPreviewUrl: mint assh -L 127.0.0.1:<localPort>:127.0.0.1:<remote>and returnhttp://127.0.0.1:<localPort>. The cloud-provider layer adds the Portless<box-name>.localhostalias on top β handled provider-side rather than in the backend, so the backend stays focused on plumbing.- β
startInBoxPortless(h, {boxName, proxyPort, tls, webPort}): brings up aportlessproxy inside the VPS that mirrors the host's mode sohttps://<boxName>.localhostresolves to the same content from both the host browser and the in-box browser. Idempotent βportless proxy startexits 0 on a running proxy. Node hascap_net_bind_serviceset in the base snapshot so binding 80/443 inside the VPS doesn't need sudo. TheportlessCLI is already baked in byinstall-box.sh:316.- β
In-box CA trust (TLS mode): the mirror serves its own self-signed CA at
/root/.portless/ca.pem.portless proxy startonly trusts it in the Linux system store β not the box user's NSS db, which Chromium / Playwright read β so the VNC browser window and Playwright-via-Codex used to fail with a cert error onhttps://<box>.localhost. Whentls,startInBoxPortlessnow runs the bakedagentbox-portless-trust /root/.portless/ca.pemhelper (system store viaupdate-ca-certificates+ vscode NSS db viacertutil, idempotent, best-effort) and dropsNODE_EXTRA_CA_CERTSinto/etc/profile.d/.libnss3-tools(thecertutilbinary) is now baked byinstall-box.sh. The Docker provider gets the same trust step increate.tsagainst the bind-mounted host CA at/home/vscode/.portless/ca.pemwhen the host proxy is TLS. Requires a snapshot re-bake (agentbox prepare --provider hetzner) / docker image rebuild to pick uplibnss3-tools+ the helper.
- β
In-box CA trust (TLS mode): the mirror serves its own self-signed CA at
attachArgv: returns ssh argv with-S <controlPath>(reuses ControlMaster β no new auth, no SSH-token mint pressure unlike Daytona).createSnapshot/deleteSnapshot: maps to Hetznercreate_image(no-pause by default β matchesdocker commitsemantics + the user's "optionally without pausing").deleteSnapshotis idempotent on 404. Both labeled withagentbox.role=ckpt+agentbox.box=<sandboxId>for orphan discovery.- No
ensureVolumeβ Hetzner has no shared-volume primitive suitable for agent credentials (the cloud-provider layer probes for it and degrades cleanly).
- β
Module-level
tunnels = new SshTunnelManager()so the ControlMaster persists across CloudBackend method calls within one CLI invocation.ensureLiveTarget(sandboxId)is the load-bearing helper that re-fetches the VPS IP from Hetzner, ensures the tunnel is open, and returns a ready-to-useSshTargetArgs. - β
Box-env passthrough: anything in
req.envprefixedAGENTBOX_*lands in/etc/agentbox/box.envvia the per-box cloud-initwrite_filesblock β except the relay/bridge tokens (cloudInitBoxEnvstrips them;box.envis 0644). The relay token reaches in-box ctl via the daemon's0600 /run/agentbox/relay.envinstead, the bridge token via the daemon process env. This closed the regression where the cloud agent'sagentbox-ctl git pushfailed "no relay configured" (the daemon's box.env overwrite, b9e4ebf55, had dropped the token login shells relied on). Seehost-relay.md. - β
AGENTBOX_HETZNER_FIREWALL_SOURCEenv var override (passed viareq.envorprocess.env) lets advanced users force a specific firewall source CIDR at create time (mirrors--firewall-sourceforprepare).
Phase 5 β Per-project snapshot tier + checkpoints β (partial β checkpoint surface complete; per-project snapshot tier deferred)
- β
box.defaultCheckpointHetznerlookup is plumbed throughresolveDefaultCheckpoint(cfg, 'hetzner')(Phase 1).agentbox checkpoint set-default --provider hetzner <ref>and--checkpoint <ref>paths work via the existing cloud-provider scaffolding because:createSnapshot(h, name)+deleteSnapshot(name)are both implemented on the backend.- The cloud-checkpoint manifest layout at
~/.agentbox/cloud-checkpoints/hetzner/<projectSegment>/<name>/manifest.jsonis already backend-agnostic; no schema changes needed. agentbox create --provider hetzner --checkpoint <name>resolves the manifest, passesreq.snapshot = <description>to provision, the backend'sresolveImageIdlooks up the snapshot by description, and provision boots from it (skipping workspace seeding because the snapshot already has/workspace).
- β
Per-project snapshot tier β resolved (not a separate feature). The tier is the checkpoint +
set-defaultflow above:agentbox checkpoint create <box> setup --set-defaultwrites a per-project (hash-keyed) manifest andbox.defaultCheckpointHetzner, and subsequent creates boot from it and skip workspace seeding. Auto-capture at the end of setup is already driven cross-provider by the/agentbox-setupskill, which runsagentbox-ctl checkpoint --name setup --replace --set-default(apps/cli/share/agentbox-setup/SKILL.md). So there's no separateprojects[<hash>]registry and nobackend.afterFirstCreate?hook to build β the earlyprojectsfield inprepared-state.tswas never wired and has been removed. (This is the same for docker β the setup skill, not core create logic, drives the auto-checkpoint, by design.) - βοΈ
--pauseflag onagentbox checkpoint createβ deferred. TheCloudBackend.createSnapshot?(h, name)interface doesn't take an options arg, so wiring--pauserequires extending the interface (which affects daytona). For Hetzner the default is already no-pause (matching the user's requirement anddocker commit's default). Users wanting a quiescent snapshot can manuallyagentbox pause <box> && agentbox checkpoint create <box> name && agentbox start <box>.
Phase 6 β agentbox hetzner CLI subcommands β
- β
agentbox hetzner loginβ interactiveHCLOUD_TOKENsetup with browser auto-open + dashboard link, validates by callingGET /locations, persists to~/.agentbox/secrets.env. - β
agentbox hetzner login --statusβ prints currently-configured token (masked) + source (env vs secrets.env) + endpoint override. - β
agentbox hetzner firewall sync <box> [--source <cidr>]β re-detects egress IP (or honors explicit--source), updates the per-box firewall viasetFirewallRules. Resolves the box viaresolveBoxRef+ reads the firewall id from the Hetzner server'sagentbox.firewalllabel. - β
agentbox hetzner firewall show <box>β prints the current rule set + the host's current egress IP, with aWARNline when they don't match (catches the "I moved networks and ssh times out" diagnostic case). - β
agentbox prepare --provider hetznerβ dispatched via the registry'sgetProviderβhetznerProvider.prepare. Help text + theisKnownProvidervalidation message updated to includehetzner. - βοΈ
agentbox prune --provider hetznerβ not wired into the CLI'sprunecommand. The provider scaffolding's prune flow usesCloudBackend.list?()which IS implemented, so the underlying primitive works; only the CLI dispatcher needs the case added. Follow-up: drop thecase 'hetzner'intoapps/cli/src/commands/prune.ts:resolveCloudBackendForPrune.
Phase 7 β End-to-end verification + docs β (live-smoked on 2026-05-24)
Live smoke run completed against a real Hetzner project. Results:
| Step | Command | Result |
|---|---|---|
| 1 | agentbox hetzner login | β
HCLOUD_TOKEN persisted to ~/.agentbox/secrets.env; --status shows masked token |
| 2 | agentbox prepare --provider hetzner -y | β
Temp VPS bootstrapped, install ran, snapshot agentbox-base-mpk9vljx (2.4 GB) created, VPS + firewall cleaned up. hetzner-prepared.json written. ~12 min wall clock |
| 3 | agentbox create -y -n smoke --provider hetzner | β VPS provisioned + firewall locked to host's egress IP (94.62.212.253/32) + SSH ControlMaster up. ~90s wall clock |
| 4 | agentbox shell smoke --no-tmux -- uname -a | β Returns kernel info over SSH ControlMaster (no per-call handshake) |
| 5 | agentbox status smoke | β Shows web preview (http://127.0.0.1:NNNN), relay preview, bridge token set |
| 6 | agentbox url smoke --print | β Returns SSH-forwarded loopback URL |
| 7 | agentbox hetzner firewall show smoke | β Shows the SSH-only rule + host's current egress IP for drift comparison |
| 8 | DinD: docker run --rm hello-world inside box | β dockerd PID 1226 running, /var/run/docker.sock present, container pulled + ran ("Hello from Docker!") |
| 9 | agentbox checkpoint create smoke setup | β
no-pause snapshot taken (box uptime after still reads ~2min); manifest at ~/.agentbox/cloud-checkpoints/hetzner/...; Hetzner image agentbox-ckpt-... available |
| 10 | agentbox destroy smoke -y | β Server + firewall both deleted (no orphans). Final resource count: 0 servers, 0 firewalls, 2 snapshots (the base + the user's checkpoint) |
Two cosmetic issues found and patched mid-smoke:
- β
Hetzner deprecated
cx22(early 2026) β switched default tocx23(same 2 vCPU / 4 GB / 40 GB x86 shape). - β
Hetzner Ubuntu 24.04 image enforces first-login password expiry for root β patched cloud-init with
chpasswd: expire: false+passwd -d root+chagebelt-and-braces (plus same fix for the per-box vscode cloud-init). - β
deletePerBoxFirewallwas racing the server-delete + 422'ing onresource_in_useβ added poll-and-retry (60s deadline, exponential backoff) covering both 409conflictand 422resource_in_use. - β
Cloud-provider scaffolding defaults
req.imageto'agentbox/box:dev'(docker tag) β backend'sresolveImageIdnow recognizes it as the Hetzner base-snapshot sentinel. - β
Parallel scp uploads were racing sshd's
MaxStartups 10:30:100, leaving some destination files 0-byte β serialized.
Phase-7 follow-ups (in the Follow-ups section below):
- β
Chromium AppArmor user-ns block (post-2026-05-25). On Ubuntu 24.04 (Hetzner's default),
kernel.apparmor_restrict_unprivileged_userns=1blocks Chromium's zygote sandbox β every in-boxagent-browser/chromiuminvocation died withFATAL: No usable sandbox!. The host VPS is itself the isolation boundary, so relaxing the knob is safe.install-box.shnow writes/etc/sysctl.d/99-agentbox-userns.conf(setsapparmor_restrict_unprivileged_userns=0+unprivileged_userns_clone=1) and applies it inline so the rest of the install can use Chromium too. Future snapshot rebakes pick it up automatically. - Cosmetic:
agentbox checkpoint createsays "daytona snapshot" in user-facing text even for hetzner boxes (the message is in the shared cloud-provider scaffolding). - Cosmetic:
agentbox checkpoint lsreturns "no checkpoints" from /tmp paths because the project-hash anchor reads/private/tmp/...vs/tmp/...differently β minor lookup-path inconsistency, manifest is correctly written. - Cosmetic:
/usr/local/bin/chromiumsymlink missing (the last command of the Chromium download step) βAGENT_BROWSER_EXECUTABLE_PATHshould fallback to the playwright cache; tracked. - Diagnostic: the install script's bash -x trace + file writes silently stop after
playwright install chromiumfor unknown reasons (steps after Chromium download don't materialize on the snapshot even though the script exits 0). Worked around by reordering install-box.sh β all baked helper scripts + config files + sshd hardening drop-in + login-shell shim + credential pivot run BEFORE the Chromium download. Snapshot is now complete. Root cause still unknown.
Original verification recipe (kept for re-runs):
# 1. Auth + bake the base snapshot (~10-15 min on first run, ~β¬0.02 in VPS time).
node apps/cli/dist/index.js hetzner login # interactive, paste a Hetzner project token
node apps/cli/dist/index.js prepare --provider hetzner -y
# Watch: tail -f ~/.agentbox/logs/latest.log
# 2. Cold create + firewall lock-down check.
node apps/cli/dist/index.js create -y -n hetzner-smoke --provider hetzner
# From a separate shell, scan with nmap β only port 22 should be open, and
# only from the host's egress IP.
# 3. Bridge / exec / Portless symmetry.
node apps/cli/dist/index.js exec hetzner-smoke -- uname -a
node apps/cli/dist/index.js url hetzner-smoke # https://hetzner-smoke.localhost
# Same URL works on host AND in-box (a portless proxy runs inside the VPS too):
curl http://hetzner-smoke.localhost:1355 # host-side via host portless
node apps/cli/dist/index.js exec hetzner-smoke -- curl http://hetzner-smoke.localhost:1355
# Smoke fixture for end-to-end Portless: cd examples/express-ready && cp .env.example .env
# 4. DinD inside the VPS.
node apps/cli/dist/index.js exec hetzner-smoke -- docker run --rm hello-world
# 5. Checkpoint (no-pause) + restore.
node apps/cli/dist/index.js checkpoint create hetzner-smoke setup
node apps/cli/dist/index.js destroy hetzner-smoke -y
node apps/cli/dist/index.js create -y -n hetzner-smoke2 --provider hetzner --checkpoint setup
# 6. Egress-IP drift recovery.
# (toggle a VPN, thenβ¦)
node apps/cli/dist/index.js exec hetzner-smoke2 -- true # should now fail
node apps/cli/dist/index.js hetzner firewall sync hetzner-smoke2
node apps/cli/dist/index.js exec hetzner-smoke2 -- true # should now succeed
# 7. Destroy + dashboard cleanup check.
node apps/cli/dist/index.js destroy hetzner-smoke2 -y
# Hetzner dashboard: server gone, firewall gone, snapshot retained.
Docs updates:
- β
docs/cloud-providers.mdβ added thehetznerrow to the provider matrix + a Β§3 "The Hetzner shape" subsection (mirrors Β§2 "The Daytona shape" with sub-sections for topology + prepare flow + SSH tunnel manager + checkpoints + DinD + shape differences + CLI surface). Β§4 Authentication extended with thehetzner loginparagraph; remaining Β§s renumbered. - β
CLAUDE.mdβ intro paragraph mentions all three backends; architecture overview gained ahetznerbullet; checkpoint paragraph namesdefaultCheckpointHetzner; "Important notes" describes the hetzner credential + state-file paths; doc map listsdocs/hertzner_backlog.md.
Follow-ups / postponed
Items deferred during implementation or surfaced during Phase 7 live smoke. Each has the workaround documented above.
-
2026-07-10: compose + buildx CLI plugins now baked (
docker-compose-v2+docker-buildxapt packages ininstall-box.sh, mirroringDockerfile.box) β user report: docs advertised Compose stacks butdocker composewasn't a command. Snapshots baked before this need a re-prepare --provider hetzner. -
install-box.sh diagnostic mystery (Phase 7). Across three independent prepare runs on Hetzner Ubuntu 24.04 cx23,
bash -x /tmp/agentbox-install.sh 2>&1 | sudo tee /var/log/agentbox/install.logconsistently truncates the trace exactly at the end ofsudo -u vscode -H bash -lc 'playwright install chromium', and no file system changes from script lines beyond that point materialize on the snapshot β yet the install exits 0 and the orchestrator sees success. Direct repro ofbash -x -c "echo A; sudo -u vscode -H bash -lc 'echo X'; echo B"works correctly, so it's not asudoFD-mangling issue per se. Worked around by reorderinginstall-box.shso all small file-install steps (baked helpers, baked configs, sshd hardening drop-in, login-shell shim, credential pivot) run BEFORE the Chromium download. The reorder makes the snapshot complete. Real diagnosis is open: candidate causes are (a) Playwright/Node closing an inherited FD that propagates to our outer bash, (b) some apparmor/seccomp interaction killingteemid-write, (c) a tmpfs / journald race. Next steps: tryscript -q -c '...' /var/log/agentbox/install.loginstead of pipe-tee, or run the install via systemd-run to isolate the session. -
RESOLVED (2026-06-27): Claude install silently skipped β box has no
claude, attach loops on "no server running on /tmp/tmux-1000/default".claude.ai+downloads.claude.aisit behind Cloudflare, which intermittently returns HTTP 403 to cloud-datacenter egress IPs (Hetzner among them) under load β re-testing minutes later returned 200. The bake'scurl -fsSL https://claude.ai/install.sh | bash -s stablemasked this:curl -fexits non-zero on the 403 but the pipeline's status isbash's 0, so the step "succeeded" while baking a claude-less snapshot β and every box from it had noclaude, so the in-box agent tmux session died instantly andattachcrash-looped. Fixed ininstall-box.sh(and mirrored in vercelprovision.sh/ e2bbuild-template.sh/ dockerDockerfile.box): aretry_backoffhelper retries the native installer 3 times with 60s then 240s backoff (~5 min budget), keepingset -o pipefailand foldingcommand -v claudeinto the retried command so a "succeeded but absent" result also retries; if all 3 attempts fail the bake aborts (exit 71) β a failedpreparebeats a claude-less snapshot.prepareHetznerspecial-casesexit 71with an actionable error ("native installer unreachable after retries β transient Cloudflare 403 on the datacenter IP, wait and re-runprepare --force") instead of the opaque generic "(empty stderr)" message (bash -x β¦ 2>&1 | teemerges stderr into stdout, so the captured stderr is always empty). No npm fallback (an earlier attempt used one, butnpm install -g @anthropic-ai/claude-codelacks native-only features the user relies on and lands the binary at/usr/bin/claude, which mismatches the host-seededinstallMethod=nativeand trips Claude Code's startup "claude command at ~/.local/bin/claude missing or broken" doctor warning). Action required: rebake the base snapshot (agentbox prepare --provider hetzner --force) β the fix only applies to new bakes.- KNOWN GAP / deferred follow-up: the 403 can outlast the ~5-min retry window, so
preparecan still fail and need a manual re-run. The real reliability fix (validated by PoC, not yet built): host-proxy the native binary.claude.ai/install.shjust downloadsdownloads.claude.ai/claude-code-releases/{version}/{platform}/claude(a complete ~244 MB self-contained ELF) and runs<binary> install(which re-fetches from the same blocked CDN βclaude installhas NO offline mode). PoC confirmed: the downloaded binary, placed directly at~/.local/bin/claude(skippinginstall), runs fully offline and satisfies theinstallMethod=nativedoctor check. So the fix is: duringprepare, the host (always gets 200) downloads the binary for the VPS arch (cx23=linux-x64; cax=linux-arm64), checksum-verifies against{version}/manifest.json, caches it under~/.agentbox/, scp's it to the VPS via the runtime-assets push, andinstall-box.shdrops it at~/.local/bin/claude(chmod+chown vscode) instead of curling. True native, 100% reliable, no npm. Deferred per user (kept retries + clear error for now); apply to vercel/e2b too if they start 403ing.
- KNOWN GAP / deferred follow-up: the 403 can outlast the ~5-min retry window, so
-
agentbox checkpoint createsays "daytona snapshot" for hetzner boxes (cosmetic). The progress message lives in shared cloud-provider code that hardcodes "daytona". Cheap fix: parameterize viabackend.name. -
agentbox checkpoint lsreturns empty from /tmp project dirs. The macOS/private/tmp/...vs/tmp/...resolution mismatches the project-hash anchor used at create time. Manifests are correctly written; only the lookup is sensitive. -
/usr/local/bin/chromiumsymlink missing on the baked snapshot. It's the very last command of the Chromium step, AFTER the playwright download β gets eaten by the same install-script truncation mystery above. Workaround: agent-browser falls back to the cached playwright binary path. Real fix: move theln -sfline to an EARLIER step (or after agent-browser install). -
--pauseflag onagentbox checkpoint create. Requires extendingCloudBackend.createSnapshot?(h, name)signature to accept options. Workaround: manualpauseβcheckpoint createβstartsequence. -
agentbox prune --provider hetzner. The backend'slist()is implemented; only the CLI dispatcher inapps/cli/src/commands/prune.tsneeds a one-line case added. -
True zero-cost pause (delete-and-respawn-from-per-box-snapshot). Hetzner bills stopped VPSes (~β¬4/mo for
cx23). v1'spause/resume=poweroff/poweron. Future: capture a per-box snapshot before delete; respawn from it on resume. -
@agentbox/sandbox-hetznerpublished-as-CLI runtime contract test. Daytona has one (apps/cli/test/cloud-e2e.test.tsgated onDAYTONA_API_KEY); a parallelhetzner-e2e.test.tsgated onHCLOUD_TOKENwould cover the fullcreate β exec β destroycycle against a real Hetzner account. -
IPv6-first SSH. The backend reads
public_net.ipv4.ip. Switch to v6 when the host network is v6-only (Hetzner returns both addresses, but our resolution prefers v4 today). -
Block Volumes. Not used in v1 β
/workspacelives on the VPS root disk, so checkpoints snapshot the whole disk. Per-server block volume for workspace would make checkpoints cheaper. -
preparelog-file integration. The DaytonaprepareusesopenCommandLog('prepare')for clean tee'd output. The Hetznerpreparecurrently just redirects to a manual log path during smoke runs β wireopenCommandLogfor parity. Tee through clack-spinner output is unreadable.
Known caveats β already documented in the plan
These ship with v1 by design; tracked here so they're discoverable next time someone wonders.
- Idle VPS cost. Hetzner
cx22is ~β¬4/mo even when stopped. v1'spause/resumeliterally stops the VPS β billing continues. See the "true zero-cost pause" follow-up. - Provision latency. ~30β60s cold (cloud-init); ~15β20s from base snapshot. The
createprogress UI already streams steps. - No live
top/stats. Hetzner basic API doesn't expose per-server CPU/mem. Cloud rows renderβ, matching Daytona. - IPv4 only. SSH targets
public_net.ipv4.ipβ IPv6 not exercised yet.
Quick reference β where each piece lives
| Concern | File |
|---|---|
| Provider entry + composition | packages/sandbox-hetzner/src/index.ts |
| CloudBackend implementation | packages/sandbox-hetzner/src/backend.ts |
| SSH ControlMaster + forwards | packages/sandbox-hetzner/src/ssh-tunnel.ts |
agentbox prepare orchestration | packages/sandbox-hetzner/src/prepare.ts |
| install-box.sh (Dockerfile.box mirror) | packages/sandbox-hetzner/scripts/install-box.sh |
| Cloud-init generator | packages/sandbox-hetzner/src/cloud-init.ts |
| REST client + typed errors | packages/sandbox-hetzner/src/client.ts |
| Retry wrapper | packages/sandbox-hetzner/src/retry.ts |
| Egress-IP detection | packages/sandbox-hetzner/src/egress-ip.ts |
| Firewall create/sync/delete | packages/sandbox-hetzner/src/firewall.ts |
| Credentials + login | packages/sandbox-hetzner/src/credentials.ts |
| Env-loader (HCLOUD_TOKEN β secrets.env) | packages/sandbox-hetzner/src/env-loader.ts |
| Persisted base/project snapshot state | packages/sandbox-hetzner/src/prepared-state.ts |
| Runtime asset resolver | packages/sandbox-hetzner/src/runtime-assets.ts |
agentbox hetzner CLI | packages/sandbox-hetzner/src/cli.ts |
| CLI registration | apps/cli/src/index.ts + apps/cli/src/provider/registry.ts |
| Relay backend resolver | packages/relay/src/host-actions.ts |
| Runtime asset staging (publish) | apps/cli/scripts/stage-runtime.mjs |