History

Merge pull request #13 from davis7dotsh/t3code/docker-container-volume

tx9 Go CLI: full v1 command surface on the Docker agent/executor runtime

Ben Davis committed 4 months ago

Commit 4ce8cee2 parents 972029c 0f3302a84 files · +6732 −3232Browse files

Unified · Split

84 files
  1. .depot/workflows/check.yml +6 −0
  2. .dockerignore +6 −0
  3. .github/workflows/release.yml +64 −0
  4. .gitignore +7 −0
  5. Makefile +42 −5
  6. README.md +70 −220
  7. assets.go +18 −0
  8. box +0 −1109
  9. docker/Dockerfile +43 −0
  10. docker/entrypoint.sh +25 −0
  11. docker/executor-entrypoint.sh +21 −0
  12. docs/docker-architecture.md +231 −0
  13. docs/tx9-cli-design.md +126 −0
  14. go.mod +38 −0
  15. go.sum +112 −0
  16. guest/hb +49 −11
  17. internal/archive/format.go +201 −0
  18. internal/archive/format_test.go +170 −0
  19. internal/archive/gpg.go +211 −0
  20. internal/archive/gpg_test.go +125 −0
  21. internal/archive/validator.go +402 −0
  22. internal/archive/validator_test.go +340 −0
  23. internal/assets/assets.go +114 −0
  24. internal/assets/assets_test.go +64 −0
  25. internal/box/box.go +272 −0
  26. internal/box/box_test.go +107 −0
  27. internal/box/create.go +270 −0
  28. internal/cli/box_common.go +109 −0
  29. internal/cli/box_common_test.go +53 −0
  30. internal/cli/cmd_backup.go +333 −0
  31. internal/cli/cmd_create.go +112 −0
  32. internal/cli/cmd_delete.go +63 −0
  33. internal/cli/cmd_doctor.go +101 −0
  34. internal/cli/cmd_enter.go +82 −0
  35. internal/cli/cmd_import.go +195 −0
  36. internal/cli/cmd_list.go +63 −0
  37. internal/cli/cmd_list_test.go +31 −0
  38. internal/cli/cmd_open.go +70 −0
  39. internal/cli/cmd_prune.go +168 −0
  40. internal/cli/cmd_startstop.go +60 −0
  41. internal/cli/cmd_upgrade.go +137 −0
  42. internal/cli/dispatch.go +137 −0
  43. internal/docker/client.go +317 −0
  44. internal/docker/ephemeral.go +137 −0
  45. internal/docker/labels.go +31 −0
  46. internal/lock/lock.go +42 −0
  47. internal/names/names.go +103 −0
  48. internal/names/names_test.go +69 −0
  49. internal/selfupdate/asset.go +12 −0
  50. internal/selfupdate/asset_test.go +19 −0
  51. internal/selfupdate/checksums.go +54 −0
  52. internal/selfupdate/checksums_test.go +66 −0
  53. internal/selfupdate/github.go +93 −0
  54. internal/selfupdate/selfupdate.go +240 −0
  55. internal/selfupdate/selfupdate_test.go +216 −0
  56. internal/selfupdate/version.go +73 −0
  57. internal/selfupdate/version_test.go +50 −0
  58. internal/state/state.go +138 −0
  59. internal/token/token.go +19 −0
  60. internal/token/token_test.go +25 −0
  61. internal/version/version.go +6 −0
  62. main.go +12 −0
  63. "a/ops/systemd/mnt-davis\\x2dvault-tx9.automount" "b/ops/systemd/mnt-davis\\x2dvault-tx9.automount" +0 −9
  64. "a/ops/systemd/mnt-davis\\x2dvault-tx9.mount" "b/ops/systemd/mnt-davis\\x2dvault-tx9.mount" +0 −10
  65. ops/systemd/tx9-backup-prune@.service +0 −16
  66. ops/systemd/tx9-backup-prune@.timer +0 −10
  67. ops/systemd/tx9-backup@.service +0 −18
  68. ops/systemd/tx9-backup@.timer +0 −10
  69. ops/systemd/tx9-box@.service +0 −26
  70. ops/systemd/tx9-health@.service +0 −17
  71. ops/systemd/tx9-health@.timer +0 −10
  72. ops/tx9-backup-prune +0 −70
  73. ops/tx9-host +0 −106
  74. provision/provision.sh +16 −0
  75. scripts/install.sh +114 −0
  76. tests/cli-surface.sh +0 −194
  77. tests/fixtures/smolvm +0 −105
  78. tests/lib.sh +3 −3
  79. tests/lifecycle-smoke.sh +0 −258
  80. tests/regressions-box-core.sh +0 −90
  81. tests/regressions-doctor-mcp.sh +0 −357
  82. tests/regressions-lock-rollback.sh +0 −326
  83. tests/regressions-tx9-host.sh +0 −157
  84. tests/static.sh +29 −95
.depot/workflows/check.yml+6−0
@@ -15,6 +15,12 @@ jobs: · 14 unmodified lines
1515 runs-on: depot-ubuntu-latest # was: ubuntu-latest. Mapped standard GitHub runner to Depot equivalent.
1616 steps:
1717 - uses: actions/checkout@v4
18+ - name: Set up Go
19+ # `make check` now runs go-check (gofmt/vet/build/test on the tx9
20+ # CLI); keep this in sync with go.mod's `go` directive.
21+ uses: actions/setup-go@v5
22+ with:
23+ go-version: "1.26"
1824 - name: Install jq
1925 run: |
2026 sudo apt-get update
.dockerignore+6−0
@@ -0,0 +1,6 @@
1+# Keep the build context tiny: only box.env, provision/, guest/, docker/ are used.
2+*
3+!box.env
4+!provision/
5+!guest/
6+!docker/
.github/workflows/release.yml+64−0
@@ -0,0 +1,64 @@
1+name: release
2+
3+# Tag-push CI for tx9's distribution (docs/tx9-cli-design.md decision 10):
4+# push a `v*` tag, get cross-compiled binaries + checksums.txt attached to
5+# a GitHub release that `internal/selfupdate` (via `tx9 upgrade`) and
6+# scripts/install.sh both know how to consume.
7+
8+on:
9+ push:
10+ tags:
11+ - "v*"
12+
13+permissions:
14+ contents: write
15+
16+jobs:
17+ release:
18+ runs-on: ubuntu-latest
19+ steps:
20+ - uses: actions/checkout@v4
21+
22+ - name: Set up Go
23+ uses: actions/setup-go@v5
24+ with:
25+ go-version: "1.26"
26+
27+ - name: Install jq
28+ run: |
29+ sudo apt-get update
30+ sudo apt-get install -y --no-install-recommends jq
31+
32+ - name: Install shellcheck
33+ # Pinned to match .depot/workflows/check.yml and local dev, so a
34+ # release build never fails a check that `make check` passed
35+ # locally for a different reason (older/newer shellcheck).
36+ env:
37+ SHELLCHECK_VERSION: v0.11.0
38+ run: |
39+ curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" \
40+ | tar -xJ
41+ sudo install "shellcheck-${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
42+ shellcheck --version
43+
44+ # `make check` (syntax + shellcheck + static contracts + guest-script
45+ # regressions + go-check) needs no Docker daemon and no network
46+ # access: none of tests/*.sh shell out to `docker` (checked — they
47+ # only grep the repo's Docker-related source files/strings), so
48+ # nothing here is trimmed down; the full local gate runs unmodified
49+ # before a release is ever built.
50+ - name: make check
51+ run: make check
52+
53+ - name: Derive VERSION from tag
54+ id: version
55+ run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
56+
57+ - name: Build dist/ (cross-compiled binaries + checksums.txt)
58+ run: make dist VERSION=${{ steps.version.outputs.version }}
59+
60+ - name: Create GitHub release
61+ uses: softprops/action-gh-release@v2
62+ with:
63+ files: dist/*
64+ generate_release_notes: true
.gitignore+7−0
@@ -14,3 +14,10 @@ · 13 unmodified lines
1414 !/artifacts/.gitkeep
1515
1616 __pycache__/
17+/.boxd-locks/
18+/.boxd/
19+
20+# Go build output.
21+/bin/
22+/tx9
23+/dist/
Makefile+42−5
@@ -1,27 +1,30 @@
1−.PHONY: check syntax lint test check-hermes-pin
1+.PHONY: check syntax lint test check-hermes-pin tx9 go-check dist
22
33 # tests/regressions-*.sh is globbed so new split-out regression files don't
44 # require touching this Makefile.
5−SHELL_FILES := box guest/hb guest/hb-workload guest/lib-mcp.sh guest/profile.sh guest/agent-bash-profile.sh provision/provision.sh ops/tx9-host ops/tx9-backup-prune tests/lib.sh tests/static.sh tests/lifecycle-smoke.sh tests/hermes-state.sh tests/cli-surface.sh $(wildcard tests/regressions-*.sh) tests/fixtures/smolvm
5+SHELL_FILES := guest/hb guest/hb-workload guest/lib-mcp.sh guest/profile.sh guest/agent-bash-profile.sh provision/provision.sh docker/entrypoint.sh docker/executor-entrypoint.sh tests/lib.sh tests/static.sh tests/hermes-state.sh $(wildcard tests/regressions-*.sh)
66
77 syntax:
88 bash -n $(SHELL_FILES)
9+ sh -n scripts/install.sh
910 python3 -c 'compile(open("guest/hermes-state", encoding="utf-8").read(), "guest/hermes-state", "exec")'
1011
1112 lint:
1213 command -v shellcheck >/dev/null || { echo "shellcheck is required for make check" >&2; exit 1; }
1314 shellcheck -x -s bash $(SHELL_FILES)
15+ # scripts/install.sh is a #!/bin/sh installer (must run under plain
16+ # POSIX sh via `curl | sh`), so it's linted against the sh dialect
17+ # separately rather than folded into SHELL_FILES/-s bash above.
18+ shellcheck -x -s sh scripts/install.sh
1419
1520 test:
1621 command -v python3 >/dev/null || { echo "python3 is required for local regression tests" >&2; exit 1; }
1722 command -v jq >/dev/null || { echo "jq is required for local regression tests" >&2; exit 1; }
1823 ./tests/static.sh
19− ./tests/lifecycle-smoke.sh
2024 ./tests/hermes-state.sh
21− ./tests/cli-surface.sh
2225 for f in tests/regressions-*.sh; do ./"$$f" || exit 1; done
2326
24−check: syntax lint test
27+check: syntax lint test go-check
2528
2629 # Not part of `check` — needs network access, so `make check` stays hermetic.
2730 # Run this on its own to catch Hermes installer drift before it surfaces as a
@@ -45,3 +48,37 @@ check-hermes-pin: · 17 unmodified lines
4548 echo "review the new installer before re-pinning, then update box.env" >&2; \
4649 exit 1; \
4750 fi
51+
52+VERSION ?= dev
53+
54+# -buildvcs=false: this worktree's .git is a linked-worktree gitlink file, not
55+# a directory, and `go build`'s VCS-root walk doesn't recognize it — it walks
56+# past the real repo and can land on an unrelated .git elsewhere up the tree,
57+# failing with "error obtaining VCS status: exit status 128". The CLI's
58+# version comes from -ldflags, not VCS stamping, so this is a no-op for us.
59+tx9:
60+ go build -buildvcs=false -ldflags "-X github.com/davis7dotsh/tx9/internal/version.Version=$(VERSION)" -o bin/tx9 .
61+
62+go-check:
63+ @test -z "$$(gofmt -l .)" || { echo "gofmt: unformatted files:" >&2; gofmt -l . >&2; exit 1; }
64+ go vet -buildvcs=false ./...
65+ go build -buildvcs=false ./...
66+ go test -buildvcs=false ./...
67+
68+# Cross-compiles the release binaries + checksums.txt into dist/. Asset
69+# naming here MUST match internal/selfupdate.AssetName and
70+# .github/workflows/release.yml: plain, uncompressed executables named
71+# tx9_<GOOS>_<GOARCH>, plus a checksums.txt in `sha256sum` output format.
72+DIST_PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64
73+
74+dist:
75+ rm -rf dist
76+ mkdir -p dist
77+ for platform in $(DIST_PLATFORMS); do \
78+ goos=$${platform%/*}; goarch=$${platform#*/}; \
79+ out="dist/tx9_$${goos}_$${goarch}"; \
80+ echo "building $$out"; \
81+ GOOS=$$goos GOARCH=$$goarch go build -buildvcs=false -ldflags "-X github.com/davis7dotsh/tx9/internal/version.Version=$(VERSION)" -o "$$out" . || exit 1; \
82+ done
83+ cd dist && { command -v sha256sum >/dev/null 2>&1 && sha256sum tx9_* > checksums.txt || shasum -a 256 tx9_* > checksums.txt; }
84+ @echo "dist/ ready (VERSION=$(VERSION))"
README.md+70−220
@@ -1,247 +1,97 @@
1−# hermes-box-lite
2−
3−Small, isolated agent VMs with **Claude Code, Codex, Hermes, and Executor**.
4−Each box runs on [smolvm](https://smolmachines.com), has no host filesystem
5−mounts, and keeps portable user state under `/data`.
6−
7−[Usage guide and quick start](docs/usage-guide.html)
8−
9−This is deliberately simpler than full Hermes Box: there are no release locks,
10−offline artifact closures, or transactional component updates. The reliability
11−boundary is narrower: safe lifecycle operations and verified state backups.
1+# tx9
2+
3+Isolated, portable agent boxes with **Claude Code, Codex, Hermes, and
4+Executor**, running on Docker. Each box is a pair of containers on a private
5+network — a free-rein agent container and an isolated Executor container —
6+with all durable state on a named volume that travels as an encrypted,
7+validated backup archive.
8+
9+**Status: experiment, mid-transition.** The bash prototypes (smolvm `./box`
10+and the compose `./docker/boxd`) have been retired in favor of a Go CLI
11+(`tx9`, this repo, embedding its provisioning and guest assets) that is now
12+implemented and under active development — still experimental, not yet
13+released. See [docs/tx9-cli-design.md](docs/tx9-cli-design.md) for the full
14+design and [docs/docker-architecture.md](docs/docker-architecture.md) for
15+the runtime architecture and its verification history.
1216
1317 ## Model
1418
1519 ```text
16−./box new alpha
17− │ Ubuntu VM + pinned Hermes runtime
20+tx9 create (Go CLI, embedded build assets)
1821 ▼
19− smolvm "alpha"
20− │ managed tools: /opt/hermes-box and /usr/local
21− │ portable state: /data/home/agent
22− │ .claude · .codex · .hermes · .executor · workspace · XDG state
23− │
24− │ ./box save alpha
22+ compose-style project per box ─────────────────────────────────┐
23+ │ ┌───────────────────────┐ ┌─────────────────────────┐ │
24+ │ │ agent ("linux land") │ │ executor │ │
25+ │ │ claude·codex·hermes │ │ Executor daemon only │ │
26+ │ │ gateway, sudo, free │ │ dashboard / + MCP /mcp │ │
27+ │ │ agent volume = the box│ │ scratch volume │ │
28+ │ │ no published ports │ │ one 0.0.0.0 host port │ │
29+ │ └───────────┬───────────┘ └───┬─────────────────────┘ │
30+ │ │ http://executor:4788 │ bearer-token gated │
31+ │ └── private network ───┘ │
32+ └──────────────────────────────────────────────────────────────┘
2533 ▼
26− backups/alpha-<timestamp>.tar.gz.gpg
27− encrypted, validated state archive plus runtime-version manifest
34+ <box>-<timestamp>.tx9 encrypted, validated state archive
2835 ```
2936
30−The VM protects the host filesystem. Networking is always enabled because
31−provisioning and the agent tools require internet access. Tools inside one box
32−share the `agent` account, credentials, network, and passwordless guest sudo;
33−they are one trust domain.
34−
35−`/data` is an organizational boundary on the VM's persistent root overlay, not
36−a separate virtual disk. Deleting or losing the VM loses live state, so keep
37−current backups for anything important.
38−
39−## Quick start
40−
41−Host prerequisites:
37+The agent volume is the box; containers are disposable; tools are image
38+layers ("upgrade" = rebuild image + recreate containers). Executor state is
39+scratch and does not travel. Networking is always enabled because the agent
40+tools require internet access.
4241
43−- macOS with the smolvm-supported virtualization stack, or Linux on x86_64 or
44− arm64 with KVM available and the current user allowed to access `/dev/kvm`;
45−- `smolvm`, GPG, curl, jq, tar, awk, and standard Bash command-line tools; and
46−- a graphical opener for `box open` (`open` on macOS, or `xdg-open`/`gio` on
47− Linux). On another desktop, set `BOX_BROWSER` to a graphical opener. It
48− receives the secret authenticated URL directly as its sole argument, so do
49− not use a wrapper that logs arguments.
42+## Repository layout
5043
51−On Linux, verify KVM access before creating a box:
52−
53−```bash
54−test -r /dev/kvm -a -w /dev/kvm
55−smolvm machine ls
56−```
57−
58−```bash
59−./box new alpha
60−./box doctor alpha
61−./box enter alpha
62−# Inside: hermes setup --portal, claude, codex
63−# After setup and single-writer gates, use the confirmed gateway-enable command below.
64−
65−./box open alpha
66−./box save alpha
67−
68−# Restore elsewhere. The archive is decrypted and validated before a VM is made.
69−./box load backups/alpha-20260625-120000.tar.gz.gpg beta
70−
71−# Import a native backup from another Hermes host. Gateway remains disabled.
72−./box import-hermes beta /staging/hermes-backup.zip --map /Users/davis=/data/home/agent
73−```
74−
75−Set `BOX_PASSPHRASE` for unattended encryption/decryption. Otherwise GPG
76−passphrases are read from the terminal without echo.
44+| Path | Role |
45+|---|---|
46+| `box.env` | Build/runtime configuration: tool pins, Hermes installer SHA pin, feature flags |
47+| `provision/provision.sh` | Runs inside `docker build` (`tools` + `assets` layers); installs everything |
48+| `guest/` | In-box runtime: `hb` control CLI, `hb-workload` reconcile loop, `hermes-state` validator, profiles, tmux config |
49+| `docker/` | Image build assets: `Dockerfile`, agent + executor entrypoints |
50+| `docs/` | Architecture, CLI design, operations history |
51+| `tests/` | Static contract checks and guest-script regressions (`make check`) |
7752
78−## Commands
53+## In-box control (`hb`)
7954
80−| Command | Behavior |
81−|---|---|
82−| `box new <name>` | Create, provision, wire MCP, and health-check a box; attempt verified cleanup on failure |
83−| `box enter <name>` | Log in as `agent` and attach persistent tmux session `main` |
84−| `box ls [--all]` | List managed boxes, or every smolvm VM |
85−| `box open <name>` | Open the authenticated Executor UI without printing its token |
86−| `box doctor <name>` | Read-only health check; deliberate quiescence is reported without resuming services |
87−| `box repair <name>` | Re-run provisioning, refresh managed assets, and re-check health |
88−| `box stop` / `start <name>` | Stop or start a VM under a per-box mutation lock |
89−| `box save <name> [out]` | Pause Executor, archive `/data`, validate, encrypt, and verify without overwriting |
90−| `box load <file> [name]` | Validate first, provision a clean VM, restore while Executor is paused, then verify |
91−| `box import-hermes <name> <zip> [--map OLD=NEW]` | Validate and atomically import native Hermes state; leave its gateway disabled |
92−| `box extract <file> [dir]` | Validate and extract an archive without creating a VM |
93−| `box rm <name> [--force]` | Show backup status and require exact-name confirmation unless forced |
94−
95−Mutating commands use local lock directories so concurrent operations cannot
96−modify the same box or registry entry.
97−
98−## Executor and MCP
99−
100−Executor runs continuously on guest `127.0.0.1:4788`. If web exposure is
101−enabled, smolvm forwards a unique host-loopback port in the configured
102−`4790–4999` range. `box open` reads Executor's stable local token and hands the
103−authenticated URL directly to the browser without printing the credential.
104−
105−`box open` launches a browser on the machine running `./box` itself (via
106−`BOX_BROWSER`, `xdg-open`, or `gio open`). On a headless host reached over SSH
107−or Tailscale rather than at its console, that handoff can report success
108−without any browser becoming visible to you — these openers consider the job
109−done once they hand the URL to a desktop session or portal, not once a human
110−sees a window. To reach the UI from a separate client machine instead, forward
111−the host-loopback port yourself, e.g. `ssh -L <port>:127.0.0.1:<port> <host>`,
112−read the live token from inside the box with `./box enter` then `hb
113−executor-token` (so it never touches host logs, shell history, or `box`'s own
114−output), and open `http://localhost:<port>/?_token=<token>` in a browser on
115−that client machine.
116−
117−The workload keeps one Executor daemon alive. Claude and Codex connect to that
118−daemon's authenticated Streamable HTTP endpoint; they do not launch `executor
119−mcp` as a second process. The bearer token remains under `/data` in Executor's
120−mode-0600 auth file and a mode-0600 Codex environment file. Set
121−`WIRE_EXECUTOR_MCP=0` before provisioning to disable registration.
122−
123−The guest doctor check—and the host check when web exposure is enabled—performs
124−a complete MCP handshake: it uses `jq` to validate the JSON `initialize`
125−result and negotiated protocol version, sends the
126−initialized notification, and closes the session best-effort. Requests have
127−bounded timeouts. A listening socket, malformed result, 404 page, or HTTP 500
128−response does not count as healthy. Without an exposed port, doctor explicitly
129−reports that the host endpoint check was skipped.
130−
131−Hermes runs as a supervised foreground gateway alongside Executor. Fresh and
132−imported boxes begin with the gateway durably disabled, so setup and migration
133−cannot accidentally create a second Discord writer. Enable it explicitly only
134−after authentication or the final single-writer cutover. Inside a box:
55+Inside the agent container:
13556
13657 ```bash
137−hb status
138−hb doctor
139−hb versions
140−hb down # durable pause; the workload will not immediately restart it
58+hb status / hb doctor / hb versions
59+hb down # durable pause; the reconcile loop won't restart services
14160 hb up
142−hb acknowledge-active-paths
143−hb cutover-ready
14461 hb gateway-enable --confirm-single-writer I_CONFIRM_NO_OTHER_GATEWAY_USES_THIS_IDENTITY
14562 hb gateway-disable
14663 hb verify-state
147−hb wire-mcp # replace stale/legacy wiring with authenticated HTTP MCP
64+hb wire-mcp # (re)register Executor's authenticated HTTP MCP for claude+codex
14865 hb logs executor
14966 ```
15067
151−## Backup and restore guarantees
152−
153−`box save`:
154−
155−1. acquires the box mutation lock;
156−2. gracefully quiesces Hermes and Executor, waits for shutdown, checkpoints
157− SQLite, and runs integrity checks;
158−3. atomically writes a mode-0600 runtime-version manifest at
159− `~/.config/hermes-box/runtime-manifest` and archives `/data` inside the guest;
160−4. excludes caches, native dependency trees, checkpointed Hermes SQLite
161− sidecars, PIDs, and locks while preserving unrelated `/data` database files,
162− then copies the archive using `smolvm machine cp`;
163−5. validates its paths and required durable-home layout;
164−6. encrypts to a unique temporary destination;
165−7. decrypts and lists the encrypted result as a verification pass; and
166−8. publishes it without overwriting an existing backup.
167−
168−Host and guest plaintext temporaries and operation locks are removed by exit
169−and signal traps. Guest plaintext is removed before services are selectively
170−resumed to their prior state, even
171−when a later save step fails. Do not actively edit workspace files during a
172−save; Executor is quiesced, but arbitrary editor or agent processes are not
173−frozen.
174−
175−`box load` decrypts and validates before spending time or creating resources.
176−It restores while Executor is paused, removes hidden as well as normal old
177−state into staging, verifies databases before the swap, refreshes
178−repository-owned guest assets, keeps the Hermes gateway disabled, starts a fresh
179−Executor daemon, repairs MCP wiring, and runs the doctor. A failed new or
180−load operation—including interruption by HUP, INT, or TERM—attempts to remove
181−only an incomplete destination VM proven to have been created by that
182−invocation. Registry metadata is removed only after VM absence is verified. If
183−cleanup cannot delete the VM or acquire the registry lock, it preserves
184−metadata and prints recovery guidance. An unmanaged or ambiguous same-named
185−smolvm machine is never deleted.
186−
187−Backups are state-portable, not VM images. Hermes itself is pinned by full Git
188−SHA and rebuilt for the guest architecture. Ubuntu, Claude, Codex, and Executor
189−packages can still drift; the runtime manifest records their versions.
190−
191−Native `hermes backup` imports add ZIP traversal/symlink validation, staging and
192−duplicate normalized-path rejection, atomic swap, SQLite integrity and inventory
193−baselines, and active-file-only path
194−rewrites. Historical sessions, memories, databases, and logs are not rewritten.
195−Validation also requires an importable canonical root Hermes marker after all
196−skip rules, so a transient-only archive cannot replace existing state with an
197−empty home.
198−The source ZIP is opened read-only and remains unchanged. Root-level
199−`~/.hermes/tmp` content and otherwise importable files with thin Mach-O magic or
200−a structurally valid universal Mach-O header are deterministically skipped
201−before staging, including beneath approved external provider trees. The fat
202−header validation distinguishes the shared `CAFEBABE` prefix from portable Java
203−class files. A successful import with exclusions emits a warning and records
204−exact skipped paths, byte counts, reasons, and normalized file/byte totals in
205−the import manifest; it does not silently treat macOS binaries as portable.
206−External provider state is previewed and rejected unless the operator explicitly
207−approves every safe top-level destination with repeatable `--external NAME`
208−flags; reserved destinations such as `.ssh`, `.config`, and shell startup files
209−are never imported. Ordinary supplemental trees remain separate.
210−See [Nexus operations and Eventide migration](docs/nexus-operations.md) for the
211−host systemd units, scheduled encrypted backups, NAS rules, and cutover gates.
212−
213−## Tool installation and state
214−
215−| Tool | Installation | Durable state |
216−|---|---|---|
217−| Claude Code | current npm package under `/opt/hermes-box` | `~/.claude` |
218−| Codex | current npm package under `/opt/hermes-box` | `~/.codex` |
219−| Hermes | official installer at `HERMES_GIT_SHA`, optionally seeded by a local Git bundle | `~/.hermes` |
220−| Executor | current npm package under `/opt/hermes-box` | `~/.executor` plus XDG directories |
221−| Neovim | Ubuntu package | XDG config/data/state |
222−
223−Configuration lives in `box.env`. The provisioning script installs a root-owned
224−copy at `/etc/hermes-box.env`, so guest helpers honor `INSTALL_HERMES`,
225−`INSTALL_EXECUTOR`, `EXECUTOR_PORT`, and `WIRE_EXECUTOR_MCP` consistently.
226−New boxes default to 4 CPUs, 8 GiB memory, and a configurable 64 GiB overlay.
227−Optional Hermes API forwarding is disabled by default and uses per-box,
228−collision-checked loopback ports when enabled. Registry rows remain compatible
229−with legacy two-column entries.
68+Fresh and restored boxes begin with the Hermes gateway durably disabled so
69+setup and migration cannot create a second Discord writer; enabling it is
70+always an explicit, confirmed step. Executor runs remotely from the agent's
71+perspective (`EXECUTOR_HOST=executor`): `hb` checks reachability and wires
72+MCP with the injected bearer token; it never spawns or stops the daemon.
73+
74+## Backup guarantees
75+
76+Saves quiesce the gateway, checkpoint SQLite, archive agent `/data` (minus
77+caches, PIDs, locks, and DB sidecars), validate the archive member-by-member
78+(absolute paths, traversal, special files, duplicate members, unsafe
79+symlink/hardlink targets, link cycles), encrypt with GPG AES-256, then
80+decrypt-verify before publishing without overwriting. Restores validate
81+before creating anything, stage-then-promote inside a throwaway container,
82+re-arm the quiesce and gateway-disable markers, mint a fresh executor token,
83+and rewire MCP automatically.
23084
23185 ## Local validation
23286
23387 ```bash
234−make check
88+make check # syntax + shellcheck + static contracts + regressions; hermetic
89+make check-hermes-pin # network: compare HERMES_INSTALLER_SHA256 against upstream
23590 ```
23691
237−This requires ShellCheck, Python 3, and jq, and runs Bash syntax checks, static
238−contract checks, protocol-health regressions, and local archive/lifecycle smoke
239−tests. It does not create a VM or contact upstream installers. `make check` is
240−fully hermetic, so `.depot/workflows/check.yml` runs it on every push and
241−pull request to `main` via Depot CI.
242−
243−If `./box new` fails immediately with "Hermes installer checksum verification
244−failed", upstream has rotated `install.sh` since `HERMES_INSTALLER_SHA256` was
245−last pinned. Run `make check-hermes-pin` (needs network access, so it's not
246−part of `make check`) to confirm the drift, review the new installer, then
247−update `box.env` yourself — this check does not auto-update the pin.
92+`.depot/workflows/check.yml` runs `make check` on every push and pull request
93+to `main` via Depot CI.
94+
95+If an image build fails with "Hermes installer checksum verification failed",
96+upstream rotated `install.sh` since the pin: run `make check-hermes-pin`,
97+review the new installer, then update `box.env` yourself.
assets.go+18−0
@@ -0,0 +1,18 @@
1+// Package main embeds the Docker build context (provision/, guest/,
2+// docker/Dockerfile + entrypoints, box.env) so tx9 ships a self-contained
3+// binary with no repo checkout required (decision 1, tx9-cli-design.md).
4+//
5+// This lives at the module root — not under internal/ — because go:embed
6+// patterns are resolved relative to the source file's directory and cannot
7+// reach outside it; provision/, guest/, and docker/ are siblings of this
8+// file, not of internal/assets. Since this file must share its directory
9+// with main.go (package main), and Go forbids importing a package literally
10+// named "main", internal/assets cannot import BuildContext directly. Instead
11+// main.go passes BuildContext down through internal/cli into internal/assets
12+// functions that accept an fs.FS parameter.
13+package main
14+
15+import "embed"
16+
17+//go:embed provision guest docker/Dockerfile docker/entrypoint.sh docker/executor-entrypoint.sh box.env
18+var BuildContext embed.FS
box+0−1109
@@ -1,1109 +0,0 @@
1−#!/usr/bin/env bash
2−# hermes-box-lite — isolated, portable agent boxes whose durable state lives in
3−# /data. Runtime: smolvm. Backups are encrypted state archives, not VM images.
4−set -euo pipefail
5−
6−ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7−cd "$ROOT"
8−# shellcheck source=box.env
9−source "$ROOT/box.env"
10−# shellcheck source=guest/lib-mcp.sh
11−source "$ROOT/guest/lib-mcp.sh"
12−
13−REG="$ROOT/.boxes"
14−LOCK_ROOT="$ROOT/.box-locks"
15−_BOX_PASS=""
16−_TEMP_PATHS=()
17−_LOCK_DIRS=()
18−_PAUSED_BOXES=()
19−_PAUSED_RESUME_ON_CLEANUP=()
20−_GUEST_TEMP_BOXES=()
21−_GUEST_TEMP_PATHS=()
22−_CREATED_BOXES=()
23−_STOP_AFTER_BOXES=()
24−_PENDING_SIGNAL=0
25−_LOCK_FAILURE=""
26−
27−die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
28−say() { printf '\033[36m==>\033[0m %s\n' "$*"; }
29−warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; }
30−have() { command -v "$1" >/dev/null 2>&1; }
31−
32−_cleanup() {
33− local status=$? n path index
34− trap - EXIT HUP INT TERM
35− set +e
36− for index in "${!_GUEST_TEMP_PATHS[@]}"; do
37− n="${_GUEST_TEMP_BOXES[$index]}"
38− path="${_GUEST_TEMP_PATHS[$index]}"
39− [[ -n "$n" && -n "$path" ]] || continue
40− smolvm machine exec --name "$n" -- rm -f -- "$path" >/dev/null 2>&1 || true
41− done
42− for path in "${_TEMP_PATHS[@]:-}"; do
43− [[ -n "$path" ]] && rm -f -- "$path"
44− done
45− for n in "${_CREATED_BOXES[@]:-}"; do
46− [[ -n "$n" ]] || continue
47− _rollback_created "$n" || true
48− done
49− for index in "${!_PAUSED_BOXES[@]}"; do
50− n="${_PAUSED_BOXES[$index]}"
51− [[ -n "$n" && "${_PAUSED_RESUME_ON_CLEANUP[$index]}" == 1 ]] || continue
52− _guest_hb "$n" resume >/dev/null 2>&1 || true
53− done
54− for n in "${_STOP_AFTER_BOXES[@]:-}"; do
55− [[ -n "$n" ]] && smolvm machine stop --name "$n" >/dev/null 2>&1 || true
56− done
57− for path in "${_LOCK_DIRS[@]:-}"; do
58− [[ -n "$path" ]] || continue
59− if [[ -r "$path/pid" && "$(cat "$path/pid")" == "$$" ]]; then rm -f "$path/pid"; fi
60− rmdir "$path" >/dev/null 2>&1 || true
61− done
62− exit "$status"
63−}
64−
65−_install_cleanup_traps() {
66− trap _cleanup EXIT
67− _install_signal_traps
68−}
69−
70−_install_signal_traps() {
71− trap 'exit 129' HUP
72− trap 'exit 130' INT
73− trap 'exit 143' TERM
74−}
75−
76−_defer_signals() {
77− _PENDING_SIGNAL=0
78− trap '_PENDING_SIGNAL=129' HUP
79− trap '_PENDING_SIGNAL=130' INT
80− trap '_PENDING_SIGNAL=143' TERM
81−}
82−
83−_honor_pending_signal() {
84− local status="$_PENDING_SIGNAL"
85− _PENDING_SIGNAL=0
86− [[ "$status" == 0 ]] || exit "$status"
87−}
88−
89−_track_temp() { _TEMP_PATHS+=("$1"); }
90−
91−_track_guest_temp() {
92− _GUEST_TEMP_BOXES+=("$1")
93− _GUEST_TEMP_PATHS+=("$2")
94−}
95−
96−_untrack_guest_temp() {
97− local n="$1" path="$2" index
98− for index in "${!_GUEST_TEMP_PATHS[@]}"; do
99− if [[ "${_GUEST_TEMP_BOXES[$index]}" == "$n" && "${_GUEST_TEMP_PATHS[$index]}" == "$path" ]]; then
100− _GUEST_TEMP_BOXES[index]=""
101− _GUEST_TEMP_PATHS[index]=""
102− fi
103− done
104−}
105−
106−_track_stop_after() { _STOP_AFTER_BOXES+=("$1"); }
107−_clear_stop_after() {
108− local n="$1" index
109− for index in "${!_STOP_AFTER_BOXES[@]}"; do
110− [[ "${_STOP_AFTER_BOXES[$index]}" == "$n" ]] && _STOP_AFTER_BOXES[index]=""
111− done
112−}
113−
114−_mark_created() { _CREATED_BOXES+=("$1"); }
115−
116−_clear_created() {
117− local n="$1" index
118− for index in "${!_CREATED_BOXES[@]}"; do
119− [[ "${_CREATED_BOXES[$index]}" == "$n" ]] && _CREATED_BOXES[index]=""
120− done
121−}
122−
123−_require() { have "$1" || die "$1 not found on PATH"; }
124−
125−_host_preflight() {
126− local mode="${1:-full}" cmd requirements=()
127− case "$mode" in
128− basic) requirements=(awk smolvm) ;;
129− provision) requirements=(awk smolvm tar) ;;
130− health) requirements=(awk curl jq mktemp smolvm) ;;
131− import) requirements=(awk jq mktemp python3 smolvm) ;;
132− archive) requirements=(awk gpg grep mktemp python3 tar) ;;
133− full) requirements=(awk curl gpg grep jq mktemp python3 smolvm tar) ;;
134− *) die "unknown host preflight mode '$mode'" ;;
135− esac
136− for cmd in "${requirements[@]}"; do _require "$cmd"; done
137−}
138−
139−_validate_name() {
140− local n="$1"
141− [[ "$n" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] ||
142− die "invalid box name '$n' (use 1-63 letters, numbers, dots, underscores, or dashes)"
143−}
144−
145−_try_acquire_lock() {
146− local key="$1" dir="$LOCK_ROOT/$1.lock"
147− _LOCK_FAILURE=""
148− if ! mkdir -p "$LOCK_ROOT"; then
149− _LOCK_FAILURE="could not create the lock directory"
150− return 1
151− fi
152− if ! mkdir "$dir" 2>/dev/null; then
153− local owner=""
154− [[ -r "$dir/pid" ]] && owner="$(cat "$dir/pid")"
155− if [[ "$owner" =~ ^[0-9]+$ ]] && ! kill -0 "$owner" 2>/dev/null; then
156− rm -rf "$dir"
157− if ! mkdir "$dir" 2>/dev/null; then
158− _LOCK_FAILURE="could not recover stale '$key' lock"
159− return 1
160− fi
161− else
162− _LOCK_FAILURE="another operation already holds the '$key' lock${owner:+ (pid $owner)}"
163− return 1
164− fi
165− fi
166− if ! printf '%s\n' "$$" >"$dir/pid"; then
167− rm -f -- "$dir/pid"
168− rmdir "$dir" >/dev/null 2>&1 || true
169− _LOCK_FAILURE="could not record ownership of the '$key' lock"
170− return 1
171− fi
172− _LOCK_DIRS+=("$dir")
173−}
174−
175−_acquire_lock() {
176− local key="$1"
177− _try_acquire_lock "$key" || die "$_LOCK_FAILURE"
178−}
179−
180−_release_lock() {
181− local dir="$LOCK_ROOT/$1.lock"
182− rm -f "$dir/pid"
183− rmdir "$dir" >/dev/null 2>&1 || true
184−}
185−
186−# One line per managed box: name, Executor host port, Hermes API host port,
187−# Hermes API guest bridge port. Legacy two-column rows remain valid.
188−# metadata; the VM remains the source of truth for whether an instance exists.
189−_reg_add() {
190− touch "$REG"
191− _reg_del "$1"
192− printf '%s\t%s\t%s\t%s\n' "$1" "${2:-}" "${3:-}" "${4:-}" >>"$REG"
193−}
194−_reg_del() {
195− [[ -f "$REG" ]] || return 0
196− awk -F'\t' -v n="$1" '$1!=n' "$REG" >"$REG.tmp"
197− mv "$REG.tmp" "$REG"
198−}
199−_reg_port() { [[ -f "$REG" ]] && awk -F'\t' -v n="$1" '$1==n{print $2}' "$REG"; }
200−_reg_exists() { [[ -f "$REG" ]] && awk -F'\t' -v n="$1" '$1==n{found=1} END{exit !found}' "$REG"; }
201−_reg_api_port() { [[ -f "$REG" ]] && awk -F'\t' -v n="$1" '$1==n{print $3}' "$REG"; }
202−_reg_api_guest_port() { [[ -f "$REG" ]] && awk -F'\t' -v n="$1" '$1==n{print $4}' "$REG"; }
203−
204−_machine_exists() { smolvm machine status --name "$1" >/dev/null 2>&1; }
205−_machine_running() {
206− local status
207− status="$(smolvm machine status --name "$1" 2>/dev/null)" && grep -q ': running' <<<"$status"
208−}
209−
210−_tcp_port_open() {
211− local host="$1" port="$2"
212− (exec 3<>"/dev/tcp/$host/$port") 2>/dev/null
213−}
214−
215−_port_free() {
216− awk -F'\t' -v p="$1" '$2==p || $3==p || $4==p{f=1} END{exit !f}' "$REG" 2>/dev/null && return 1
217− _tcp_port_open 127.0.0.1 "$1" && return 1
218− _tcp_port_open ::1 "$1" && return 1
219− return 0
220−}
221−
222−_wait_mcp_ready() {
223− local url="$1" token="$2" attempt
224− for ((attempt = 0; attempt < 30; attempt++)); do
225− _mcp_initialize "$url" "$token" >/dev/null 2>&1 && return 0
226− sleep 1
227− done
228− return 1
229−}
230−
231−_hermes_api_healthy() {
232− local port="$1" body code
233− body="$(mktemp)" || return 1
234− _track_temp "$body"
235− code="$(curl -sS --connect-timeout 2 --max-time 5 -o "$body" -w '%{http_code}' \
236− "http://127.0.0.1:${port}/health")" || return 1
237− [[ "$code" =~ ^2[0-9][0-9]$ ]] || return 1
238− jq -e '.status == "ok" and .platform == "hermes-agent"' "$body" >/dev/null 2>&1
239−}
240−
241−_assign_port() {
242− local lo="$1" hi="$2" label="$3" forbidden="${4:-}" p attempt
243− [[ "$lo" =~ ^[0-9]+$ && "$hi" =~ ^[0-9]+$ && "$lo" -ge 1024 && "$hi" -le 65535 && "$lo" -le "$hi" ]] ||
244− die "invalid ${label} port range ${lo}-${hi}"
245− for ((attempt = 0; attempt < 300; attempt++)); do
246− p=$((lo + RANDOM % (hi - lo + 1)))
247− [[ "$p" != "$forbidden" ]] && _port_free "$p" && { printf '%s' "$p"; return 0; }
248− done
249− die "no free host port in ${lo}-${hi}"
250−}
251−
252−_validate_resources() {
253− BOX_CPUS="${BOX_CPUS:-4}"
254− BOX_MEM_MIB="${BOX_MEM_MIB:-8192}"
255− BOX_OVERLAY_GIB="${BOX_OVERLAY_GIB:-64}"
256− BOX_STORAGE_GIB="${BOX_STORAGE_GIB:-}"
257− [[ "${BOX_CPUS:-}" =~ ^[1-9][0-9]*$ && "${BOX_CPUS}" -le 128 ]] || die "BOX_CPUS must be 1-128"
258− [[ "${BOX_MEM_MIB:-}" =~ ^[1-9][0-9]*$ && "${BOX_MEM_MIB}" -ge 512 ]] || die "BOX_MEM_MIB must be at least 512"
259− [[ "${BOX_OVERLAY_GIB:-}" =~ ^[1-9][0-9]*$ ]] || die "BOX_OVERLAY_GIB must be a positive integer"
260− [[ -z "${BOX_STORAGE_GIB:-}" || "${BOX_STORAGE_GIB}" =~ ^[1-9][0-9]*$ ]] || die "BOX_STORAGE_GIB must be empty or a positive integer"
261−}
262−
263−_validate_bundle() {
264− local bundle="${HERMES_GIT_BUNDLE:-}"
265− [[ -n "$bundle" ]] || return 0
266− _require git
267− [[ "$bundle" != /* && "$bundle" != *..* && "$bundle" == artifacts/* ]] ||
268− die "HERMES_GIT_BUNDLE must be a safe repository-relative path beneath artifacts/"
269− [[ -f "$ROOT/$bundle" ]] || die "Hermes Git bundle not found: $ROOT/$bundle"
270− git bundle verify "$ROOT/$bundle" >/dev/null 2>&1 || die "invalid Hermes Git bundle: $bundle"
271−}
272−
273−_resolve_passphrase() {
274− local confirm="${1:-}" p1 p2
275− [[ -n "$_BOX_PASS" ]] && return
276− if [[ -n "${BOX_PASSPHRASE_FILE:-}" ]]; then
277− [[ -r "$BOX_PASSPHRASE_FILE" ]] || die "BOX_PASSPHRASE_FILE is not readable"
278− IFS= read -r _BOX_PASS <"$BOX_PASSPHRASE_FILE" || true
279− [[ -n "$_BOX_PASS" ]] || die "BOX_PASSPHRASE_FILE is empty"
280− return
281− fi
282− if [[ -n "${BOX_PASSPHRASE:-}" ]]; then
283− _BOX_PASS="$BOX_PASSPHRASE"
284− return
285− fi
286− [[ -r /dev/tty ]] || die "no BOX_PASSPHRASE set and no terminal to prompt on"
287− printf 'Passphrase: ' >/dev/tty
288− read -rs p1 </dev/tty
289− printf '\n' >/dev/tty
290− if [[ "$confirm" == confirm ]]; then
291− printf 'Confirm passphrase: ' >/dev/tty
292− read -rs p2 </dev/tty
293− printf '\n' >/dev/tty
294− [[ "$p1" == "$p2" ]] || die "passphrases do not match"
295− fi
296− [[ -n "$p1" ]] || die "empty passphrase"
297− _BOX_PASS="$p1"
298−}
299−
300−gpg_enc() {
301− _resolve_passphrase confirm
302− gpg --batch --yes --pinentry-mode loopback --passphrase-fd 3 \
303− --symmetric --cipher-algo AES256 -o "$1" 3<<<"$_BOX_PASS"
304−}
305−
306−gpg_dec() {
307− _resolve_passphrase
308− gpg --batch --yes --pinentry-mode loopback --passphrase-fd 3 -d "$1" 3<<<"$_BOX_PASS"
309−}
310−
311−_validate_archive() {
312− python3 - "$1" <<'PY'
313−import sys
314−import tarfile
315−from pathlib import PurePosixPath
316−
317−def safe_text(value, label):
318− if "\x00" in value or any(ord(char) < 32 or ord(char) == 127 for char in value):
319− raise ValueError(f"{label} contains control characters")
320−
321−def link_parts(value):
322− safe_text(value, "archive link target")
323− path = PurePosixPath(value)
324− if path.is_absolute():
325− raise ValueError("archive link target is absolute")
326− return [part for part in path.parts if part not in {"", "."}]
327−
328−def validate_link_target(member, key):
329− parts = link_parts(member.linkname)
330− if not parts:
331− raise ValueError("archive link target is empty")
332− resolved = key.split("/")[:-1] if member.issym() else []
333− for part in parts:
334− if part == "..":
335− if not resolved:
336− raise ValueError("archive link target lexically escapes extraction root")
337− resolved.pop()
338− else:
339− resolved.append(part)
340−
341−try:
342− with tarfile.open(sys.argv[1], "r:gz") as archive:
343− members = archive.getmembers()
344− if not members:
345− raise ValueError("archive is empty")
346− by_name = {}
347− for member in members:
348− name = member.name
349− safe_text(name, "archive path")
350− path = PurePosixPath(name)
351− if path.is_absolute() or any(part == ".." for part in path.parts):
352− raise ValueError("archive path escapes its extraction root")
353− parts = tuple(part for part in path.parts if part not in {"", "."})
354− key = "/".join(parts)
355− if key in by_name:
356− raise ValueError(f"archive contains duplicate path: {key or '.'}")
357− if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
358− raise ValueError("archive contains a device, FIFO, socket, or unknown special file")
359− if not key and not member.isdir():
360− raise ValueError("archive root member must be a real directory")
361− if member.issym() or member.islnk():
362− validate_link_target(member, key)
363− by_name[key] = member
364−
365− for key in by_name:
366− parts = key.split("/") if key else []
367− for length in range(1, len(parts)):
368− parent = by_name.get("/".join(parts[:length]))
369− if parent is not None and (parent.issym() or parent.islnk()):
370− raise ValueError(f"archive member is nested beneath a link: {key}")
371−
372− for required in ("home", "home/agent"):
373− entry = by_name.get(required)
374− if entry is None or not entry.isdir() or entry.issym() or entry.islnk():
375− raise ValueError(f"{required} must be a real directory")
376−
377− def resolve(key):
378− pending = key.split("/") if key else []
379− resolved = []
380− seen_states = set()
381− steps = 0
382− while pending:
383− steps += 1
384− if steps > len(by_name) * 8 + 32:
385− raise ValueError("archive link chain exceeds safety bound")
386− state = (tuple(resolved), tuple(pending))
387− if state in seen_states:
388− raise ValueError("archive link cycle detected")
389− seen_states.add(state)
390− part = pending.pop(0)
391− if part in {"", "."}:
392− continue
393− if part == "..":
394− if not resolved:
395− raise ValueError("archive link target escapes extraction root")
396− resolved.pop()
397− continue
398− candidate = "/".join((*resolved, part))
399− entry = by_name.get(candidate)
400− if entry is not None and (entry.issym() or entry.islnk()):
401− base = candidate.split("/")[:-1] if entry.issym() else []
402− pending = base + link_parts(entry.linkname) + pending
403− resolved = []
404− continue
405− if pending and (entry is None or not entry.isdir()):
406− raise ValueError(f"archive path resolves through missing or non-directory member: {candidate}")
407− resolved.append(part)
408− final = "/".join(resolved)
409− entry = by_name.get(final)
410− if entry is None:
411− raise ValueError(f"archive link resolves to missing member: {final or '.'}")
412− return entry
413−
414− for key, member in by_name.items():
415− if not key:
416− continue
417− parent = key.rpartition("/")[0]
418− if parent and not resolve(parent).isdir():
419− raise ValueError(f"archive member parent is not a directory: {key}")
420− if member.issym() or member.islnk():
421− target = resolve(key)
422− if member.islnk() and not target.isfile():
423− raise ValueError("archive hardlink does not resolve to a regular file")
424−except (OSError, tarfile.TarError, ValueError) as error:
425− print(f"unsafe archive: {error}", file=sys.stderr)
426− raise SystemExit(1)
427−PY
428−}
429−
430−_guest_hb() {
431− local n="$1"
432− shift
433− smolvm machine exec --name "$n" -- \
434− setpriv --reuid=agent --regid=agent --init-groups \
435− env HOME=/data/home/agent bash --noprofile --norc -c \
436− '. /etc/profile.d/hermes-box.sh; exec hb "$@"' hb "$@"
437−}
438−
439−_pause_box() {
440− local n="$1" resume_on_cleanup="${2:-1}"
441− if _guest_hb "$n" is-paused >/dev/null 2>&1; then return; fi
442− _PAUSED_BOXES+=("$n")
443− _PAUSED_RESUME_ON_CLEANUP+=("$resume_on_cleanup")
444− if ! _guest_hb "$n" pause; then return 1; fi
445− _guest_hb "$n" is-paused >/dev/null 2>&1
446−}
447−
448−_resume_box() {
449− local n="$1" index
450− for index in "${!_PAUSED_BOXES[@]}"; do
451− if [[ "${_PAUSED_BOXES[$index]}" == "$n" ]]; then
452− _guest_hb "$n" resume || return 1
453− _PAUSED_BOXES[index]=""
454− _PAUSED_RESUME_ON_CLEANUP[index]=""
455− return
456− fi
457− done
458−}
459−
460−_create_base() {
461− local n="$1" port="" api_port="" api_guest_port="" wl_port create_status opts=() resources=()
462− _validate_resources
463− _validate_bundle
464− _acquire_lock registry
465− ! _reg_exists "$n" || { _release_lock registry; die "box '$n' is already registered"; }
466− if _machine_exists "$n"; then
467− _release_lock registry
468− die "smolvm machine '$n' already exists"
469− fi
470− if [[ "${INSTALL_EXECUTOR:-0}" == 1 && "${EXPOSE_EXECUTOR_WEB:-0}" == 1 ]]; then
471− port="$(_assign_port "${EXECUTOR_PORT_LO:-4790}" "${EXECUTOR_PORT_HI:-4999}" executor)" || { _release_lock registry; return 1; }
472− opts+=(-p "${port}:${port}")
473− say "Assigned host port ${port} -> executor (http://localhost:${port})"
474− fi
475− if [[ "${INSTALL_HERMES:-0}" == 1 && "${EXPOSE_HERMES_API:-0}" == 1 ]]; then
476− api_port="$(_assign_port "${HERMES_API_PORT_LO:-8650}" "${HERMES_API_PORT_HI:-8850}" 'Hermes API' "$port")" || { _release_lock registry; return 1; }
477− api_guest_port="$api_port"
478− opts+=(-p "${api_port}:${api_guest_port}")
479− say "Assigned host port ${api_port} -> Hermes API (http://localhost:${api_port})"
480− fi
481− resources=(--cpus "$BOX_CPUS" --mem "$BOX_MEM_MIB" --overlay "$BOX_OVERLAY_GIB")
482− [[ -z "${BOX_STORAGE_GIB:-}" ]] || resources+=(--storage "$BOX_STORAGE_GIB")
483− wl_port="${port:-4788}"
484− _defer_signals
485− # No `exec` into hb-workload: if the loop process itself dies (OOM-killed, a
486− # bug not guarded by its `set -u`), nothing else in the VM restarts it, so
487− # this outer loop is the supervisor. hb-workload's own internals (gateway,
488− # socat bridges) are reconciled every 20s once it's running again.
489− if smolvm machine create --net "${opts[@]}" "${resources[@]}" --name "$n" --image "$BASE_IMAGE" \
490− -- bash -c "while [ ! -x /opt/hermes-box/bin/hb-workload ]; do sleep 2; done
491− while true; do runuser -u agent -- env HOME=/data/home/agent /opt/hermes-box/bin/hb-workload ${wl_port} ${api_guest_port:-0}; sleep 2; done"; then
492− create_status=0
493− else
494− create_status=$?
495− fi
496− if [[ "$create_status" != 0 ]]; then
497− _install_signal_traps
498− _release_lock registry
499− warn "smolvm create failed for '$n'; ownership is unknown, so no VM will be deleted automatically"
500− warn "inspect with: smolvm machine status --name '$n'"
501− _honor_pending_signal
502− return 1
503− fi
504− _mark_created "$n"
505− if ! _reg_add "$n" "$port" "$api_port" "$api_guest_port"; then
506− _install_signal_traps
507− _release_lock registry
508− _honor_pending_signal
509− return 1
510− fi
511− _install_signal_traps
512− _release_lock registry
513− _honor_pending_signal
514− smolvm machine start --name "$n"
515−}
516−
517−_provision_into() {
518− local n="$1" mode="${2:-full}" log_file="${3:-}" contexts=(provision guest box.env) cmd
519− if [[ "$mode" == full ]]; then
520− _validate_bundle
521− [[ -z "${HERMES_GIT_BUNDLE:-}" ]] || contexts+=("$HERMES_GIT_BUNDLE")
522− fi
523− say "Streaming provision context into '$n'"
524− if ! COPYFILE_DISABLE=1 tar czf - -C "$ROOT" "${contexts[@]}" \
525− | smolvm machine exec -i --name "$n" -- bash -lc 'cat >/tmp/ctx.tgz'; then
526− return 1
527− fi
528− if [[ "$mode" == assets ]]; then
529− say "Refreshing managed guest assets"
530− else
531− say "Provisioning node/uv, claude, codex, hermes, executor (a few minutes)…"
532− fi
533− cmd="set -e
534− rm -rf /tmp/ctx && mkdir -p /tmp/ctx
535− tar xzf /tmp/ctx.tgz -C /tmp/ctx
536− bash /tmp/ctx/provision/provision.sh '$mode'"
537− # Optionally tee the transcript to a host-side file so a caller can persist
538− # it as failure evidence before rollback deletes the only other copy.
539− if [[ -n "$log_file" ]]; then
540− smolvm machine exec --name "$n" -- bash -lc "$cmd" 2>&1 | tee "$log_file"
541− else
542− smolvm machine exec --name "$n" -- bash -lc "$cmd"
543− fi
544−}
545−
546−_prepare_runtime() {
547− local n="$1"
548− _guest_hb "$n" up || return 1
549− _guest_hb "$n" wire-once || return 1
550− _guest_hb "$n" doctor
551−}
552−
553−# Best-effort: a failure saving the transcript (full disk, read-only checkout)
554−# must never itself abort the caller's failure-handling path under set -e —
555−# that would skip keep-on-failure's _clear_created and let the EXIT trap roll
556−# the box back anyway, defeating the one thing the operator needed most.
557−_save_failed_transcript() {
558− local n="$1" log_file="$2" failed_log
559− [[ -s "$log_file" ]] || return 0
560− if ! mkdir -p backups/.failed; then
561− warn "could not create backups/.failed; provisioning transcript was not saved"
562− return 0
563− fi
564− failed_log="backups/.failed/${n}-$(date -u +%Y%m%dT%H%M%SZ).log"
565− if cp "$log_file" "$failed_log"; then
566− warn "provisioning transcript saved to $failed_log"
567− else
568− warn "could not save provisioning transcript to $failed_log"
569− fi
570−}
571−
572−_rollback_created() {
573− local n="$1" registry_lock="$LOCK_ROOT/registry.lock" owns_lock=0
574− warn "rolling back incomplete box '$n'"
575− if ! smolvm machine delete --name "$n" -f >/dev/null 2>&1; then
576− warn "could not remove incomplete box '$n'; registry metadata was preserved"
577− warn "recover with: ./box rm '$n' --force"
578− return 1
579− fi
580− if _machine_exists "$n"; then
581− warn "could not remove incomplete box '$n'; registry metadata was preserved"
582− warn "recover with: ./box rm '$n' --force"
583− return 1
584− fi
585− if [[ -r "$registry_lock/pid" && "$(cat "$registry_lock/pid" 2>/dev/null)" == "$$" ]]; then
586− owns_lock=1
587− elif _try_acquire_lock registry; then
588− owns_lock=1
589− fi
590− if [[ "$owns_lock" != 1 ]]; then
591− warn "removed VM '$n', but the registry lock is busy; metadata was preserved"
592− warn "after the other operation finishes, remove the stale '$n' entry from $REG"
593− return 1
594− fi
595− if ! _reg_del "$n"; then
596− warn "removed VM '$n', but failed to update $REG"
597− _release_lock registry
598− return 1
599− fi
600− _release_lock registry
601− return 0
602−}
603−
604−cmd_new() {
605− local n="${1:?usage: box new <name> [--keep-on-failure]}" keep_on_failure=0 stage="" log_file
606− [[ "${2:-}" != --keep-on-failure && "${BOX_KEEP_ON_FAILURE:-0}" != 1 ]] || keep_on_failure=1
607− _validate_name "$n"
608− _host_preflight provision
609− _acquire_lock "$n"
610− say "Creating instance '$n' from $BASE_IMAGE"
611− log_file="$(mktemp)"
612− _track_temp "$log_file"
613− if ! _create_base "$n"; then
614− stage="creating the base VM"
615− elif ! _provision_into "$n" full "$log_file"; then
616− stage="provisioning"
617− elif ! _prepare_runtime "$n"; then
618− stage="preparing the runtime (gateway start, MCP wiring, doctor)"
619− fi
620− if [[ -n "$stage" ]]; then
621− _save_failed_transcript "$n" "$log_file"
622− if [[ "$keep_on_failure" == 1 ]]; then
623− _clear_created "$n"
624− warn "leaving '$n' in place for inspection (--keep-on-failure / BOX_KEEP_ON_FAILURE=1)"
625− warn "inspect with: smolvm machine exec --name '$n' -- bash"
626− warn "remove when done with: ./box rm '$n' --force"
627− fi
628− die "box creation failed while $stage"
629− fi
630− _clear_created "$n"
631− local port
632− port="$(_reg_port "$n")"
633− say "Ready. Enter with: ./box enter $n"
634− [[ -n "$port" ]] && say "Executor web: http://localhost:${port} (./box open $n)"
635− say "Authenticate inside, then ./box save $n to capture the state."
636−}
637−
638−cmd_enter() {
639− local n="${1:?usage: box enter <name>}"
640− _validate_name "$n"
641− _host_preflight basic
642− smolvm machine start --name "$n" >/dev/null
643− exec smolvm machine exec -it --name "$n" -- login -f agent
644−}
645−
646−cmd_save() {
647− local n="${1:?usage: box save <name> [out.tar.gz.gpg]}" ts out raw encrypted guest_tmp
648− _validate_name "$n"
649− _host_preflight full
650− _acquire_lock "$n"
651− ts="$(date +%Y%m%d-%H%M%S)"
652− out="${2:-backups/${n}-${ts}.tar.gz.gpg}"
653− [[ ! -e "$out" ]] || die "refusing to overwrite existing backup: $out"
654− mkdir -p "$(dirname "$out")"
655− raw="$(mktemp)"
656− encrypted="$(mktemp "${out}.tmp.XXXXXX")"
657− guest_tmp="/root/hb-data-${ts}-$$-${RANDOM}.tgz"
658− _track_temp "$raw"
659− _track_temp "$encrypted"
660− _track_guest_temp "$n" "$guest_tmp"
661−
662− say "Quiescing Hermes and Executor inside '$n'"
663− _pause_box "$n"
664− _guest_hb "$n" verify-state --checkpoint >/dev/null
665− _guest_hb "$n" write-manifest
666− say "Archiving /data inside '$n'"
667− # shellcheck disable=SC2016 # expanded by the guest shell; archive is positional
668− smolvm machine exec --name "$n" -- bash -c \
669− 'set -euo pipefail; umask 077; archive=$1; sync
670− tar czf "$archive" -C /data \
671− --exclude="./home/agent/.config/hermes-box/quiesced" \
672− --exclude="./home/agent/.codex/tmp" \
673− --exclude="./home/agent/.hermes/tmp" \
674− --exclude="*/node_modules" --exclude="*/.venv" --exclude="*/venv" \
675− --exclude="*/__pycache__" --exclude="*/.cache" \
676− --exclude="*.pyc" --exclude="*.pyo" \
677− --exclude="*/.hermes/*.db-wal" --exclude="*/.hermes/*.db-shm" \
678− --exclude="*/.hermes/*.db-journal" \
679− --exclude="*/gateway.pid" --exclude="*/cron.pid" --exclude="*/gateway.lock" \
680− --exclude="*/processes.json" .' bash "$guest_tmp"
681− smolvm machine cp "$n:$guest_tmp" "$raw"
682− smolvm machine exec --name "$n" -- rm -f -- "$guest_tmp"
683− _untrack_guest_temp "$n" "$guest_tmp"
684− _validate_archive "$raw" || die "generated archive failed validation"
685− _resume_box "$n"
686−
687− say "Encrypting and verifying backup"
688− gpg_enc "$encrypted" <"$raw"
689− gpg_dec "$encrypted" | tar tzf - >/dev/null || die "encrypted backup verification failed"
690− ln "$encrypted" "$out" 2>/dev/null || die "backup destination appeared during save: $out"
691− rm -f "$encrypted"
692− say "Saved and verified $(du -h "$out" | cut -f1) -> $out"
693−}
694−
695−cmd_load() {
696− local file="${1:?usage: box load <archive.tar.gz.gpg> [name] [--keep-on-failure]}" n="${2:-restored}" raw guest_tmp
697− local keep_on_failure=0 log_file stage=""
698− [[ "${3:-}" != --keep-on-failure && "${BOX_KEEP_ON_FAILURE:-0}" != 1 ]] || keep_on_failure=1
699− [[ -f "$file" ]] || die "no such archive: $file"
700− _validate_name "$n"
701− _host_preflight full
702− _acquire_lock "$n"
703− raw="$(mktemp)"
704− _track_temp "$raw"
705− say "Decrypting and validating archive before creating a VM"
706− gpg_dec "$file" >"$raw"
707− _validate_archive "$raw" || die "archive is invalid or unsafe"
708−
709− say "Creating and provisioning fresh instance '$n'"
710− log_file="$(mktemp)"
711− _track_temp "$log_file"
712− if ! _create_base "$n"; then
713− stage="creating the base VM"
714− elif ! _provision_into "$n" full "$log_file"; then
715− stage="provisioning"
716− fi
717− if [[ -n "$stage" ]]; then
718− _save_failed_transcript "$n" "$log_file"
719− if [[ "$keep_on_failure" == 1 ]]; then
720− _clear_created "$n"
721− warn "leaving '$n' in place for inspection (--keep-on-failure / BOX_KEEP_ON_FAILURE=1)"
722− warn "remove when done with: ./box rm '$n' --force"
723− fi
724− die "box load failed while $stage"
725− fi
726−
727− guest_tmp="/root/hb-restore-$(date +%Y%m%d-%H%M%S)-$$-${RANDOM}.tgz"
728− _track_guest_temp "$n" "$guest_tmp"
729− # shellcheck disable=SC2016 # expanded by the guest shell; archive is positional
730− if ! _pause_box "$n" 0 || ! smolvm machine cp "$raw" "$n:$guest_tmp" ||
731− ! smolvm machine exec --name "$n" -- chmod 0600 "$guest_tmp" ||
732− ! smolvm machine exec --name "$n" -- bash -c \
733− 'set -euo pipefail
734− archive=$1
735− trap '\''rm -f -- "$archive"'\'' EXIT HUP INT TERM
736− stage=/data/.hb-restore-stage-$$
737− rollback=/data/.hb-restore-rollback-$$
738− rm -rf "$stage" "$rollback"
739− mkdir -m 0700 "$stage" "$rollback"
740− tar xzf "$archive" --no-same-owner -C "$stage"
741− test -d "$stage/home/agent"
742− chown root:root "$stage" "$stage/home"
743− chmod 0711 "$stage" "$stage/home"
744− chown -R agent:agent "$stage/home/agent" "$stage/logs" 2>/dev/null || true
745− test "$(stat -c %u "$stage")" = 0
746− test "$(stat -c %u "$stage/home")" = 0
747− test "$(stat -c %a "$stage")" = 711
748− test "$(stat -c %a "$stage/home")" = 711
749− mkdir -p "$stage/home/agent/.config/hermes-box"
750− touch "$stage/home/agent/.config/hermes-box/quiesced" \
751− "$stage/home/agent/.config/hermes-box/gateway-disabled" \
752− "$stage/home/agent/.config/hermes-box/gateway-policy-initialized"
753− chown -R agent:agent "$stage/home/agent/.config/hermes-box"
754− setpriv --reuid=agent --regid=agent --init-groups test -x "$stage"
755− setpriv --reuid=agent --regid=agent --init-groups test ! -w "$stage"
756− setpriv --reuid=agent --regid=agent --init-groups test -x "$stage/home"
757− setpriv --reuid=agent --regid=agent --init-groups test ! -w "$stage/home"
758− setpriv --reuid=agent --regid=agent --init-groups test -w "$stage/home/agent"
759− setpriv --reuid=agent --regid=agent --init-groups env HOME=/data/home/agent \
760− /opt/hermes-box/bin/hermes-state verify --home "$stage/home/agent/.hermes" >/dev/null
761− shopt -s dotglob nullglob
762− for item in /data/*; do
763− [[ "$item" == "$stage" || "$item" == "$rollback" ]] && continue
764− mv "$item" "$rollback/"
765− done
766− restore_failed=1
767− trap '\''if [[ "$restore_failed" == 1 ]]; then find /data -mindepth 1 -maxdepth 1 ! -path "$rollback" -exec rm -rf -- {} +; mv "$rollback"/* /data/ 2>/dev/null || true; fi; rm -rf "$stage" "$rollback"; rm -f -- "$archive"'\'' EXIT HUP INT TERM
768− mv "$stage"/* /data/
769− restore_failed=0' bash "$guest_tmp" ||
770− ! _provision_into "$n" assets "$log_file" || ! _guest_hb "$n" gateway-disable ||
771− ! _guest_hb "$n" verify-state >/dev/null || ! _resume_box "$n" || ! _prepare_runtime "$n"; then
772− _save_failed_transcript "$n" "$log_file"
773− if [[ "$keep_on_failure" == 1 ]]; then
774− _clear_created "$n"
775− warn "leaving '$n' in place for inspection (--keep-on-failure / BOX_KEEP_ON_FAILURE=1)"
776− warn "remove when done with: ./box rm '$n' --force"
777− die "restore failed; left incomplete destination '$n' for inspection"
778− fi
779− die "restore failed; attempting cleanup of the incomplete destination"
780− fi
781− _untrack_guest_temp "$n" "$guest_tmp"
782− _clear_created "$n"
783− say "Restored and verified. Enter with: ./box enter $n"
784−}
785−
786−cmd_import_hermes() {
787− local n="${1:?usage: box import-hermes <name> <hermes-backup.zip> [--external NAME]... [--map OLD=NEW]...}"
788− local file="${2:?usage: box import-hermes <name> <hermes-backup.zip> [--external NAME]... [--map OLD=NEW]...}"
789− local guest_tmp map external already_paused=0 was_running=0 validation external_count archive_external planned_skips
790− shift 2
791− _validate_name "$n"
792− [[ -f "$file" ]] || die "no such Hermes backup: $file"
793− _host_preflight import
794− # Keep a blank sentinel because macOS Bash 3.2 treats an expanded empty array
795− # as unbound under `set -u`.
796− local maps=() externals=("")
797− [[ -z "${HERMES_IMPORT_PATH_MAP:-}" ]] || maps+=("$HERMES_IMPORT_PATH_MAP")
798− while (($#)); do
799− case "$1" in
800− --external)
801− [[ $# -ge 2 ]] || die "expected --external NAME"
802− external="$2"
803− [[ "$external" =~ ^[A-Za-z0-9._-]+$ && "$external" != . && "$external" != .. ]] ||
804− die "invalid external provider name '$external'"
805− case "$external" in
806− .ssh | .config | workspace | .bashrc | .bash_profile | .profile | .zshrc | .zprofile | .zshenv)
807− die "reserved external destination '$external' is never importable" ;;
808− esac
809− externals+=("$external")
810− shift 2
811− ;;
812− --map)
813− [[ $# -ge 2 ]] || die "expected --map OLD=NEW"
814− map="$2"
815− [[ "$map" == *=* && -n "${map%%=*}" && -n "${map#*=}" ]] || die "invalid path map '$map'"
816− maps+=("$map")
817− shift 2
818− ;;
819− *) die "expected --external NAME or --map OLD=NEW" ;;
820− esac
821− done
822− validation="$(python3 "$ROOT/guest/hermes-state" validate-zip "$file")" || die "Hermes backup is invalid or unsafe"
823− external_count="$(jq '.external_top_level_entries | length' <<<"$validation")"
824− planned_skips="$(jq '.migration_scope.skipped_files' <<<"$validation")"
825− if [[ "$planned_skips" != 0 ]]; then
826− warn "Native import will intentionally skip ${planned_skips} non-portable or transient file(s):"
827− jq -r '.migration_scope.skipped_by_reason | to_entries[] | " \(.key): \(.value)"' <<<"$validation" >&2
828− fi
829− if [[ "$external_count" != 0 ]]; then
830− say "External provider destinations in archive:"
831− jq -r '.external_top_level_entries[] | " /data/home/agent/" + .' <<<"$validation"
832− fi
833− while IFS= read -r archive_external; do
834− [[ -n "$archive_external" ]] || continue
835− case "$archive_external" in
836− .ssh | .config | workspace | .bashrc | .bash_profile | .profile | .zshrc | .zprofile | .zshenv)
837− die "archive requests reserved external destination '$archive_external'" ;;
838− esac
839− local approved=0
840− for external in "${externals[@]}"; do [[ "$external" == "$archive_external" ]] && approved=1; done
841− [[ "$approved" == 1 ]] || die "external provider '$archive_external' is unapproved; re-run with --external '$archive_external'"
842− done < <(jq -r '.external_top_level_entries[]' <<<"$validation")
843− for external in "${externals[@]}"; do
844− [[ -n "$external" ]] || continue
845− jq -e --arg name "$external" '.external_top_level_entries | index($name) != null' <<<"$validation" >/dev/null ||
846− die "approved external provider '$external' is not present in the archive"
847− done
848− _acquire_lock "$n"
849− _machine_exists "$n" || die "smolvm machine '$n' is missing"
850− _machine_running "$n" && was_running=1
851− [[ "$was_running" == 1 ]] || _track_stop_after "$n"
852− smolvm machine start --name "$n" >/dev/null
853− _guest_hb "$n" is-paused >/dev/null 2>&1 && already_paused=1
854− _guest_hb "$n" gateway-disable >/dev/null
855− _pause_box "$n"
856− guest_tmp="/data/home/agent/.config/hermes-box/import-$(date +%Y%m%d-%H%M%S)-$$-${RANDOM}.zip"
857− _track_guest_temp "$n" "$guest_tmp"
858− smolvm machine exec --name "$n" -- \
859− install -d -o agent -g agent -m 0700 /data/home/agent/.config/hermes-box
860− smolvm machine cp "$file" "$n:$guest_tmp"
861− smolvm machine exec --name "$n" -- chown agent:agent "$guest_tmp"
862− smolvm machine exec --name "$n" -- chmod 0600 "$guest_tmp"
863− local import_args=(import-zip "$guest_tmp")
864− for external in "${externals[@]}"; do
865− [[ -n "$external" ]] && import_args+=(--external "$external")
866− done
867− for map in "${maps[@]}"; do import_args+=(--map "$map"); done
868− say "Importing validated Hermes state transactionally into '$n'"
869− smolvm machine exec --name "$n" -- \
870− setpriv --reuid=agent --regid=agent --init-groups \
871− env HOME=/data/home/agent HERMES_HOME=/data/home/agent/.hermes \
872− /opt/hermes-box/bin/hermes-state "${import_args[@]}" >/dev/null
873− smolvm machine exec --name "$n" -- rm -f -- "$guest_tmp"
874− _untrack_guest_temp "$n" "$guest_tmp"
875− _guest_hb "$n" verify-state >/dev/null
876− if [[ "$already_paused" == 0 ]]; then _resume_box "$n"; fi
877− if [[ "$was_running" == 0 ]]; then
878− smolvm machine stop --name "$n"
879− _clear_stop_after "$n"
880− fi
881− say "Hermes import verified. Gateway remains disabled; intentional exclusions are recorded in the import manifest. Inspect active paths, acknowledge them, then run the cutover gates."
882−}
883−
884−cmd_extract() {
885− local file="${1:?usage: box extract <archive.tar.gz.gpg> [dest-dir]}" base dest raw
886− [[ -f "$file" ]] || die "no such archive: $file"
887− _host_preflight archive
888− base="$(basename "$file")"
889− base="${base%.tar.gz.gpg}"
890− dest="${2:-backups/${base}-extracted}"
891− [[ ! -e "$dest" ]] || die "$dest already exists"
892− raw="$(mktemp)"
893− _track_temp "$raw"
894− gpg_dec "$file" >"$raw"
895− _validate_archive "$raw" || die "archive is invalid or unsafe"
896− mkdir -p "$dest"
897− if ! tar xzf "$raw" --no-same-owner -C "$dest"; then
898− rm -rf "$dest"
899− die "extraction failed"
900− fi
901− say "Extracted $(find "$dest" -type f | wc -l | tr -d ' ') files -> $dest/"
902−}
903−
904−cmd_ls() {
905− _host_preflight basic
906− if [[ "${1:-}" == --all ]]; then smolvm machine ls; return; fi
907− [[ -s "$REG" ]] || { echo "No hermes-box-lite boxes yet. Create one: ./box new <name>"; return; }
908− printf '%-16s %-10s %-32s %s\n' BOX STATE EXECUTOR HERMES_API
909− local n port api_port _api_guest st url api_url
910− while IFS=$'\t' read -r n port api_port _api_guest; do
911− [[ -n "$n" ]] || continue
912− if st="$(smolvm machine status --name "$n" 2>/dev/null)"; then
913− st="${st#*: }"
914− st="${st%% *}"
915− else
916− st=missing
917− fi
918− url='—'
919− api_url='—'
920− [[ -n "$port" ]] && url="http://localhost:${port}"
921− [[ -n "$api_port" ]] && api_url="http://localhost:${api_port}"
922− printf '%-16s %-10s %-32s %s\n' "$n" "$st" "$url" "$api_url"
923− done <"$REG"
924−}
925−
926−cmd_open() {
927− local n="${1:?usage: box open <name>}" port token url
928− _validate_name "$n"
929− _host_preflight health
930− _acquire_lock "$n"
931− port="$(_reg_port "$n")"
932− [[ -n "$port" ]] || die "'$n' has no exposed Executor host port"
933− _machine_running "$n" || die "box '$n' is not running; run './box start $n' before opening Executor"
934− _guest_hb "$n" is-paused >/dev/null 2>&1 &&
935− die "box '$n' is quiesced; run './box start $n' and 'hb resume' explicitly before opening Executor"
936− _guest_hb "$n" up >/dev/null
937− token="$(_guest_hb "$n" executor-token)" || die "could not read Executor's local token"
938− [[ -n "$token" ]] || die "Executor token is unavailable"
939− _wait_mcp_ready "http://127.0.0.1:${port}/mcp" "$token" ||
940− die "Executor did not become ready on the forwarded host endpoint"
941− url="http://localhost:${port}/?_token=${token}"
942− say "Opening authenticated Executor UI at http://localhost:${port}"
943− _open_url "$url" ||
944− die "could not launch a browser; set BOX_BROWSER to a graphical opener (the authenticated URL is passed directly and never printed)"
945−}
946−
947−_open_url() {
948− local url="$1" platform
949− if [[ -n "${BOX_BROWSER:-}" ]]; then
950− command -v "$BOX_BROWSER" >/dev/null 2>&1 || return 1
951− "$BOX_BROWSER" "$url" >/dev/null 2>&1
952− return
953− fi
954− platform="$(uname -s)"
955− if [[ "$platform" == Darwin ]] && command -v open >/dev/null 2>&1; then
956− open "$url" >/dev/null 2>&1
957− return
958− elif [[ "$platform" == Linux ]]; then
959− if command -v xdg-open >/dev/null 2>&1 && xdg-open "$url" >/dev/null 2>&1; then return; fi
960− if command -v gio >/dev/null 2>&1 && gio open "$url" >/dev/null 2>&1; then return; fi
961− fi
962− return 1
963−}
964−
965−cmd_doctor() {
966− local n="${1:?usage: box doctor <name>}" port api_port token guest_quiesced=0 gateway_disabled=0
967− _validate_name "$n"
968− _host_preflight basic
969− _acquire_lock "$n"
970− port="$(_reg_port "$n")"
971− api_port="$(_reg_api_port "$n")"
972− _machine_exists "$n" || die "smolvm machine '$n' is missing"
973− _guest_hb "$n" is-paused >/dev/null 2>&1 && guest_quiesced=1
974− if [[ -n "$api_port" && "$guest_quiesced" == 0 ]]; then
975− _guest_hb "$n" gateway-is-disabled >/dev/null 2>&1 && gateway_disabled=1
976− fi
977− _guest_hb "$n" doctor || die "guest readiness or health check failed for '$n' (doctor never starts or resumes a box)"
978− if [[ -n "$port" && "$guest_quiesced" == 0 ]]; then
979− _host_preflight health
980− token="$(_guest_hb "$n" executor-token)" || die "could not read Executor's local token"
981− [[ -n "$token" ]] || die "Executor token is unavailable"
982− _wait_mcp_ready "http://127.0.0.1:${port}/mcp" "$token" ||
983− die "host Executor MCP health check failed on port $port"
984− say "host Executor MCP: healthy on http://localhost:${port}"
985− elif [[ "$guest_quiesced" == 1 ]]; then
986− say "host Executor MCP: intentionally quiesced"
987− else
988− say "host Executor MCP: skipped (no exposed host port)"
989− fi
990− if [[ -n "$api_port" && "$guest_quiesced" == 0 && "$gateway_disabled" == 0 ]]; then
991− _host_preflight health
992− _hermes_api_healthy "$api_port" || die "host Hermes API health failed on port $api_port (expected 2xx Hermes JSON)"
993− say "host Hermes API: healthy on http://localhost:${api_port}"
994− elif [[ "$guest_quiesced" == 1 ]]; then
995− say "host Hermes API: intentionally quiesced"
996− elif [[ "$gateway_disabled" == 1 ]]; then
997− say "host Hermes API: intentionally disabled by gateway policy"
998− else
999− say "host Hermes API: skipped (disabled)"
1000− fi
1001− say "doctor passed for '$n'"
1002−}
1003−
1004−cmd_repair() {
1005− local n="${1:?usage: box repair <name>}" already_paused=0
1006− _validate_name "$n"
1007− _host_preflight provision
1008− _acquire_lock "$n"
1009− _machine_exists "$n" || die "smolvm machine '$n' is missing"
1010− smolvm machine start --name "$n" >/dev/null
1011− _guest_hb "$n" is-paused >/dev/null 2>&1 && already_paused=1
1012− _pause_box "$n"
1013− _provision_into "$n" full
1014− if [[ "$already_paused" == 1 ]]; then
1015− _guest_hb "$n" doctor
1016− say "repair completed for '$n'; durable quiesce preserved"
1017− else
1018− _resume_box "$n"
1019− _prepare_runtime "$n"
1020− say "repair completed for '$n'"
1021− fi
1022−}
1023−
1024−cmd_stop() {
1025− local n="${1:?usage: box stop <name>}"
1026− _validate_name "$n"
1027− _host_preflight basic
1028− _acquire_lock "$n"
1029− smolvm machine stop --name "$n"
1030−}
1031−
1032−cmd_start() {
1033− local n="${1:?usage: box start <name>}"
1034− _validate_name "$n"
1035− _host_preflight basic
1036− _acquire_lock "$n"
1037− smolvm machine start --name "$n"
1038−}
1039−
1040−cmd_rm() {
1041− local n="${1:?usage: box rm <name> [--force]}" force="${2:-}" answer latest
1042− _validate_name "$n"
1043− _host_preflight basic
1044− latest="$(find backups -maxdepth 1 -type f -name "${n}-*.tar.gz.gpg" -print 2>/dev/null | sort | tail -1)"
1045− if [[ -n "$latest" ]]; then say "latest backup: $latest"; else warn "no backup found for '$n'"; fi
1046− if [[ "$force" != --force ]]; then
1047− [[ -r /dev/tty ]] || die "refusing non-interactive deletion without --force"
1048− printf "Type the box name '%s' to delete it permanently: " "$n" >/dev/tty
1049− read -r answer </dev/tty
1050− [[ "$answer" == "$n" ]] || die "deletion cancelled"
1051− fi
1052− _acquire_lock "$n"
1053− smolvm machine delete --name "$n" -f
1054− _acquire_lock registry
1055− _reg_del "$n"
1056− _release_lock registry
1057− say "deleted '$n'"
1058−}
1059−
1060−usage() {
1061− cat <<'EOF'
1062−hermes-box-lite — isolated, portable agent boxes (claude / codex / hermes / executor)
1063−
1064− ./box new <name> [--keep-on-failure]
1065− Create, provision, and health-check a fresh box
1066− ./box enter <name> Log in as agent and attach tmux 'main'
1067− ./box ls [--all] List managed boxes (or every smolvm VM)
1068− ./box open <name> Open the authenticated Executor web UI
1069− ./box doctor <name> Read-only health check; never starts or resumes a box
1070− ./box repair <name> Re-run provisioning and repair managed guest assets
1071− ./box stop|start <name> Stop or start an instance
1072− ./box save <name> [out] Quiesce, validate, encrypt, and verify /data
1073− ./box load <file> [name] [--keep-on-failure]
1074− Validate first, then build and restore a fresh box
1075− ./box import-hermes <name> <hermes-backup.zip> [--external NAME]... [--map OLD=NEW]...
1076− Transactionally import native Hermes state; gateway stays disabled
1077− ./box extract <file> [dir] Validate and extract a backup without a VM
1078− ./box rm <name> [--force] Delete after confirmation (or explicit --force)
1079−
1080− Set BOX_PASSPHRASE to skip the GPG prompt. Configuration: box.env
1081− Set BOX_KEEP_ON_FAILURE=1 (or pass --keep-on-failure to `new`/`load`) to
1082− skip rollback on a failed `new`/`load` and leave the VM up for inspection.
1083− A failed `new`/`load` also saves its provisioning transcript under
1084− backups/.failed/ before any rollback runs.
1085−EOF
1086−}
1087−
1088−main() {
1089− _install_cleanup_traps
1090− case "${1:-help}" in
1091− new) shift; cmd_new "$@" ;;
1092− enter | shell) shift; cmd_enter "$@" ;;
1093− ls) shift; cmd_ls "$@" ;;
1094− open) shift; cmd_open "$@" ;;
1095− doctor) shift; cmd_doctor "$@" ;;
1096− repair | provision) shift; cmd_repair "$@" ;;
1097− stop) shift; cmd_stop "$@" ;;
1098− start) shift; cmd_start "$@" ;;
1099− save) shift; cmd_save "$@" ;;
1100− load) shift; cmd_load "$@" ;;
1101− import-hermes) shift; cmd_import_hermes "$@" ;;
1102− extract) shift; cmd_extract "$@" ;;
1103− rm) shift; cmd_rm "$@" ;;
1104− help | -h | --help) usage ;;
1105− *) usage; return 1 ;;
1106− esac
1107−}
1108−
1109−if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@"; fi
docker/Dockerfile+43−0
@@ -0,0 +1,43 @@
1+# hermes-box-lite on Docker — everything baked into one image at build time.
2+#
3+# The whole provisioning story (node, uv, claude, codex, hermes, executor,
4+# guest assets, /data skeleton) is the existing provision/provision.sh, run
5+# once at BUILD time instead of streamed into a live VM on every `box new`.
6+# Creating a box becomes `docker run` + named volume: seconds, not minutes,
7+# and every box is bit-identical to the image it came from.
8+#
9+# /data is declared a VOLUME after the skeleton is seeded, so a fresh named
10+# volume is auto-populated from the image on first mount (Docker copies the
11+# image's mount-point contents into an empty named volume).
12+#
13+# Build from the repository root:
14+# docker build -t hermes-box-lite -f docker/Dockerfile .
15+
16+FROM ubuntu:24.04
17+
18+SHELL ["/bin/bash", "-o", "pipefail", "-c"]
19+
20+# Two provisioning layers so day-to-day guest-script edits don't re-run the
21+# multi-minute tool installs: `tools` (node/uv/claude/codex/hermes/executor)
22+# caches until box.env or provision.sh changes; `assets` (hb, profiles, /data
23+# skeleton) rebuilds in seconds when guest/ changes.
24+COPY box.env /tmp/ctx/box.env
25+COPY provision/ /tmp/ctx/provision/
26+RUN bash /tmp/ctx/provision/provision.sh tools
27+
28+COPY guest/ /tmp/ctx/guest/
29+RUN bash /tmp/ctx/provision/provision.sh assets && rm -rf /tmp/ctx
30+
31+COPY docker/entrypoint.sh /opt/hermes-box/bin/entrypoint
32+COPY docker/executor-entrypoint.sh /opt/hermes-box/bin/executor-entrypoint
33+RUN chmod 0755 /opt/hermes-box/bin/entrypoint /opt/hermes-box/bin/executor-entrypoint
34+
35+# Durable state. Everything else in the container is disposable image content.
36+VOLUME /data
37+
38+# Fixed internal bridge ports (hb-workload's socat bridges bind 0.0.0.0 here
39+# and forward to the loopback-only services). Hosts publish these with -p and
40+# let Docker pick free host ports — no port registry, no collision logic.
41+EXPOSE 14788 18642
42+
43+ENTRYPOINT ["/opt/hermes-box/bin/entrypoint"]
docker/entrypoint.sh+25−0
@@ -0,0 +1,25 @@
1+#!/usr/bin/env bash
2+# Container PID-1 workload (run under docker --init so signals behave).
3+# Reuses guest/hb-workload verbatim: it reconciles Executor, the Hermes
4+# gateway, and the 0.0.0.0 socat bridges every 20s. The outer loop is the
5+# supervisor for the loop process itself; Docker's restart policy is the
6+# supervisor for the container.
7+set -uo pipefail
8+
9+trap 'exit 143' TERM
10+trap 'exit 130' INT
11+
12+# Idempotent: ensures the /data skeleton and gateway-policy markers exist
13+# even when the volume predates this image (e.g. restored from a backup).
14+# Fail fast if it can't — a container without a sane /data must not look
15+# healthy, so the restart policy (not a silent loop) handles recovery.
16+/opt/hermes-box/bin/hb init || exit 1
17+
18+EXEC_BRIDGE_PORT="${BOX_EXECUTOR_BRIDGE_PORT:-14788}"
19+API_BRIDGE_PORT="${BOX_HERMES_BRIDGE_PORT:-18642}"
20+
21+while true; do
22+ runuser -u agent -- env HOME=/data/home/agent \
23+ /opt/hermes-box/bin/hb-workload "$EXEC_BRIDGE_PORT" "$API_BRIDGE_PORT" || true
24+ sleep 2
25+done
docker/executor-entrypoint.sh+21−0
@@ -0,0 +1,21 @@
1+#!/usr/bin/env bash
2+# PID-1 for the executor service container. Runs ONLY the Executor daemon,
3+# foreground, as the agent user, bound to 0.0.0.0 so both the private compose
4+# network (the agent container) and the published host port (LAN/Tailscale
5+# dashboard) can reach it. The bearer token is the gate on every request.
6+#
7+# This container is the trust boundary: the hermes/agent container never
8+# shares a namespace, filesystem, or process table with it — its only surface
9+# is this HTTP endpoint.
10+set -euo pipefail
11+
12+[[ -n "${EXECUTOR_MCP_TOKEN:-}" ]] || { echo "EXECUTOR_MCP_TOKEN is required" >&2; exit 1; }
13+
14+mkdir -p /data/home/agent /data/logs
15+chown agent:agent /data/home/agent /data/logs
16+
17+# shellcheck disable=SC2016 # $EXECUTOR_MCP_TOKEN expands in the inner shell
18+exec runuser -u agent -- env HOME=/data/home/agent \
19+ bash --noprofile --norc -c '. /etc/profile.d/hermes-box.sh
20+ exec executor daemon run --foreground --hostname 0.0.0.0 --port 4788 \
21+ --auth-token "$EXECUTOR_MCP_TOKEN"'
docs/docker-architecture.md+231−0
@@ -0,0 +1,231 @@
1+# Proposed architecture: hermes-box-lite on Docker
2+
3+Status: architecture record, 2026-07-03. The `boxd` bash prototype referenced
4+throughout was verified against this design and then **deleted** (2026-07-04)
5+in favor of the Go CLI specified in [tx9-cli-design.md](tx9-cli-design.md);
6+recover it from git history (`6f1ced3` and earlier) when porting logic. The
7+runtime topology, contracts, and verification results below remain the
8+authoritative description of what the Go CLI must create.
9+
10+## Why move off smolvm
11+
12+smolvm gives real VM isolation, but in practice it has been the least
13+reliable part of the system: bespoke lifecycle semantics, the 4 GiB
14+pack-export cap that forces overlay-slimming gymnastics, provisioning that
15+streams a tarball into a live VM on **every** `box new` (minutes, network
16+required, can fail halfway and needs rollback machinery), and `machine exec`
17+quirks that shaped a lot of defensive code in `./box`.
18+
19+Docker trades the VM boundary for a mature, boring runtime:
20+
21+- **Build once, run many.** Provisioning happens once at `docker build`;
22+ `boxd new` is a `docker run` and completes in seconds. Failed builds leave
23+ nothing behind, so `_rollback_created`, keep-on-failure, failed-transcript
24+ saving, and the create/provision/prepare staging in `cmd_new` all disappear.
25+- **The daemon is the registry.** Containers and volumes are labelled
26+ `hermes-box-lite=1`; `docker ps`/`volume ls` replace `.boxes`, and the
27+ Docker daemon's own serialization replaces `.box-locks`.
28+- **Ports without bookkeeping.** Each container exposes two fixed internal
29+ bridge ports; Docker publishes them to free host ports. No `_assign_port`,
30+ no port registry columns, no collision scanning.
31+
32+## Model
33+
34+One box = one Docker Compose project (`hbl-<name>`) with **two containers on
35+a private network**. This split exists because the main security question is
36+Executor: the agent (Hermes, claude, codex) should be free to do whatever it
37+wants in its own container, and Executor — the thing that holds real
38+credentials and takes real actions — should not share a filesystem, process
39+table, or user namespace with it.
40+
41+```text
42+./docker/boxd build (once per box.env / pin change)
43+ │ provision.sh runs INSIDE `docker build` (tools layer + assets layer)
44+ ▼
45+ image hermes-box-lite:latest one image, two entrypoints
46+ │
47+./docker/boxd new alpha (seconds)
48+ ▼
49+ compose project hbl-alpha ──────────────────────────────────────────┐
50+ │ │
51+ │ ┌────────────────────────────┐ ┌───────────────────────────┐ │
52+ │ │ agent ("linux land") │ │ executor │ │
53+ │ │ claude · codex · hermes │ │ Executor daemon ONLY │ │
54+ │ │ gateway; free rein, sudo │ │ (foreground, 0.0.0.0:4788,│ │
55+ │ │ │ │ fixed bearer token) │ │
56+ │ │ volume: agent-data → /data │ │ volume: exec-data → /data │ │
57+ │ │ THE box; save/load target │ │ executor scratch state │ │
58+ │ │ NO published ports │ │ publishes 4788 → host │ │
59+ │ └─────────────┬──────────────┘ └─────┬──────────────┬──────┘ │
60+ │ │ http://executor:4788 │ │ │
61+ │ └──── private network ─────┘ │ │
62+ └──────────────────────────── (per-project; no cross-box) ─┼────────┘
63+ ▼
64+ host 0.0.0.0:<auto> — LAN + Tailscale
65+ dashboard at / · MCP at /mcp
66+ bearer token gates every request
67+ │
68+./docker/boxd save alpha (archives agent /data only)
69+ ▼
70+ backups/alpha-<ts>.tar.gz.gpg — SAME format as ./box save; archives are
71+ interchangeable between the two runtimes
72+```
73+
74+Containers are disposable; the `agent-data` volume is the box. The executor
75+container's volume is scratch — its identity is the bearer token boxd mints
76+per box into `.boxd/<name>.env` (mode 0600) and injects into both containers.
77+`hb` inside the agent wires claude/codex MCP against `http://executor:4788/mcp`
78+with that token (`EXECUTOR_HOST` + `BOXD_EXECUTOR_TOKEN`); the same token
79+authenticates you to the dashboard/MCP from the LAN or Tailscale.
80+
81+What the agent container can do to Executor: exactly what any LAN client can
82+do — talk HTTP to the token-gated endpoint. Nothing else. Cross-box: each
83+compose project gets its own network, so `alpha`'s agent cannot even resolve
84+`beta`'s executor (verified: DNS fails).
85+
86+### What stays exactly the same
87+
88+- `provision/provision.sh` — unchanged logic, now two cached build layers
89+ (`tools` for the multi-minute installs, `assets` for repo-owned guest
90+ files, so guest-script edits rebuild in seconds).
91+- `guest/` (hb, hb-workload, hermes-state, profiles, tmux) — hb gained a
92+ remote-executor mode (`EXECUTOR_HOST` ≠ loopback → check reachability and
93+ wire MCP remotely instead of spawning/stopping a local daemon); everything
94+ else unchanged.
95+- `/data` layout, quiesce/gateway-policy markers, the single-writer gateway
96+ gates, and the encrypted backup format + `hermes-state verify`.
97+- `box.env` as the single tuning file (now mostly consumed at build time).
98+
99+### What is deleted
100+
101+| smolvm machinery | Docker replacement |
102+| --- | --- |
103+| `.boxes` registry + mkdir-lock forest + stale-lock recovery | labels + one flock per box |
104+| `_assign_port`, port ranges, registry columns | `-p 0.0.0.0::<internal>` auto-assign |
105+| per-`new` provisioning stream, rollback, keep-on-failure, failed transcripts | `docker build` atomicity |
106+| overlay 4 GiB cap workarounds (`slim`, ffmpeg preseed rationale) | image layers, no pack-export cap |
107+| `smolvm machine cp` staging + guest temp tracking | `docker exec` stdout / stdin streaming |
108+| outer while-true supervisor inside the VM spec | `--restart unless-stopped` + `--init` |
109+
110+Roughly: `./box` is ~1100 lines; `boxd` is ~300, and most of the deleted 800
111+lines were compensation for smolvm's sharp edges.
112+
113+## The trade: isolation
114+
115+Decision (2026-07-03): VM isolation is a nice-to-have; containers are enough
116+for now, and the agent keeps passwordless sudo inside its container. The
117+security question that actually matters is **Executor**, and the compose
118+split answers it structurally rather than with hardening flags:
119+
120+- The agent container is deliberately permissive — that's linux land, the
121+ Hermes agent runs around freely, sudo included. What it can reach is the
122+ network, its own volume, and one token-gated HTTP endpoint.
123+- Executor's credentials and execution surface live in a container the agent
124+ cannot exec into, whose filesystem it cannot see, whose processes it cannot
125+ signal. Compromising the agent gets you the same position as any
126+ unauthenticated LAN client: an HTTP endpoint that 401s without the token.
127+ (The agent does hold a valid token — that's its job — so "agent can call
128+ Executor tools" is by design; "agent can tamper with Executor itself"
129+ is what the split removes.)
130+- Per-project networks mean boxes can't see each other at all.
131+
132+gVisor (`--runtime=runsc`) remains the one-flag escape hatch if VM-grade
133+isolation is ever wanted back — the design doesn't change.
134+
135+Networking stance is unchanged from smolvm (always-on, tools need internet).
136+The executor's single published port binds 0.0.0.0 (LAN + Tailscale, per
137+decision); the agent container publishes nothing.
138+
139+## Lifecycle mapping
140+
141+| smolvm (`./box`) | Docker (`boxd`) | Notes |
142+| --- | --- | --- |
143+| `new` (5–10 min) | `build` once + `new` (seconds) | compose up: agent + executor + network |
144+| `enter` | `enter` | exec into the **agent** container → same tmux attach |
145+| `ls` | `ls` | agent state + executor dashboard URL per box |
146+| `doctor` | `doctor` | in-box `hb doctor` (remote-executor aware) + host dashboard probe |
147+| `save` / `load` | `save` / `load` | same archive format; archives agent /data only; restored boxes arrive quiesced + gateway-disabled with a **fresh executor token** |
148+| `repair` | `build` + `compose up` again | volumes untouched; "repair" becomes "replace" |
149+| `import-hermes` | port later | pure `hermes-state` work; runtime-agnostic |
150+| `stop`/`start`/`rm` | same | both containers together; `rm` deletes containers **and** volumes after typed confirmation |
151+
152+The "repair becomes replace" row is the philosophical center: containers are
153+cattle, volumes are pets. Any doubt about a box's runtime → delete the
154+container, `boxd new`-style recreate against the same volume. There is no
155+drift to repair because managed bits are image layers.
156+
157+## Dry-run results (2026-07-03, on nexus-class hardware, Docker 29)
158+
159+- `boxd build`: one-time, ~10 min (dominated by the Hermes installer), 5.97 GB
160+ image. No 4 GiB cap to fight; `slim()` still runs but is now optional.
161+- `boxd new dry1`: **3.4 seconds** to a fully doctor-green box (vs minutes on
162+ smolvm), including executor up + MCP wiring + full `hb doctor`.
163+- `save` → `load` roundtrip: 27 MB encrypted archive; restored box came up
164+ quiesced with gateway disabled, workspace files intact, `/data` permission
165+ topology (root 711 / root 711 / agent) preserved, `hb resume` + full doctor
166+ green afterwards.
167+- `stop`/`start` and `docker restart` preserve state; the reconcile loop
168+ revives executor after restart without intervention.
169+- Codex-driven behavioral QA (8 error-path scenarios: duplicate name, invalid
170+ name, missing box, missing archive, wrong passphrase, nonexistent rm,
171+ concurrent saves against the per-box lock): all pass, no orphaned
172+ containers/volumes after any failure path.
173+- In-box tool verification: claude 2.1.201, codex 0.142.5, hermes, executor
174+ 1.5.28, node 22, uv, nvim, tmux, passwordless sudo, and the /data-pinned
175+ CLAUDE_CONFIG_DIR/CODEX_HOME/HERMES_HOME env all check out. One cosmetic
176+ wart: codex prints "could not create PATH aliases: Operation not permitted"
177+ because the npm global prefix is a root-owned image layer — harmless
178+ (aliases already exist from build time), but worth silencing later.
179+- One real Docker-specific gotcha found and fixed: executor's daemon binds
180+ `localhost`, which in a default Docker container resolves to `::1` first —
181+ daemon listened on IPv6 loopback while every health check probed
182+ `127.0.0.1`. Fixed by running containers with
183+ `--sysctl net.ipv6.conf.all.disable_ipv6=1` (matches the smolvm guest,
184+ which had no routable IPv6 either).
185+
186+### Compose-split verification (same day, after the two-container rework)
187+
188+- `boxd new` on the compose template: agent + executor + private network up,
189+ MCP wired against `http://executor:4788/mcp`, full doctor green first try.
190+- Isolation properties verified live: agent container publishes **zero**
191+ ports; MCP returns 401 without the bearer token and 200 with it (from the
192+ host and from inside the agent); dashboard serves on the published port;
193+ `dry1`'s agent cannot resolve `dry3`'s executor (per-project networks).
194+- save/load across the split: restored box gets a **fresh token**, and
195+ `hb wire-once` detects the stale restored wiring (old token + old URL in
196+ the archive) and rewires — this was a real bug found in the dry run: the
197+ profile sources the restored `executor-mcp.env`, which clobbered the
198+ injected token env var. Fixed with a `BOXD_EXECUTOR_TOKEN` override that
199+ outranks the on-disk file, plus a URL-freshness check in `wire_once`.
200+- The Dockerfile now provisions in two layers (`tools` then `assets`), so
201+ guest-script iteration rebuilds the image in seconds instead of ~10 min.
202+
203+## Draft gaps (known, deliberate for a first pass)
204+
205+- `boxd doctor` only runs the in-box doctor; host-side MCP/API health probes
206+ from `./box doctor` aren't ported yet.
207+- `import-hermes` and `extract` aren't ported (both are runtime-agnostic and
208+ move over mechanically).
209+- `boxd save` resumes via a single EXIT-trap slot; the multi-box
210+ pause-tracking arrays from `./box` aren't needed since one boxd invocation
211+ touches one box.
212+- Restored boxes re-run `hb init` via the entrypoint, which is what re-owns
213+ the skeleton, but a `boxd load` doesn't run the `assets` provisioning
214+ refresh — with Docker it doesn't need to: managed assets are image layers
215+ and can't have been clobbered by the archive (the archive only touches
216+ /data).
217+- Restored boxes start quiesced with the gateway disabled (correct), but the
218+ `boxd load` UX for walking the cutover gates is just a printed hint.
219+- No `boxd repair` — intentionally; see above.
220+
221+## Suggested migration
222+
223+1. Land `docker/` alongside the smolvm path (no removals), mark experimental.
224+2. Run a throwaway box (`boxd new scratch`) for day-to-day agent work for a
225+ week; the state that matters is exercised by real use.
226+3. Migrate a real box: `./box save alpha` on smolvm → `boxd load` the same
227+ archive → walk the gateway gates. The shared archive format makes this a
228+ one-command migration each way, and smolvm remains the rollback.
229+4. When confident: port `import-hermes`/`extract`/full archive validation,
230+ move `tests/` fixtures from a fake `smolvm` to a fake `docker`, then
231+ retire the smolvm path.
docs/tx9-cli-design.md+126−0
@@ -0,0 +1,126 @@
1+# tx9 CLI — design
2+
3+Status: agreed 2026-07-04 (12 decisions below, settled in conversation with
4+Davis). Nothing here is implemented yet; this document is the spec the Go
5+implementation is built from. The bash prototypes that informed it (`./box`
6+on smolvm, `./docker/boxd` on compose) are deleted; recover them from git
7+history (`9350212` and earlier) when porting logic.
8+
9+## What tx9 is
10+
11+A single Go binary installed on a machine ("tx9") that creates and manages
12+hermes boxes — the two-container agent/executor pairs described in
13+[docker-architecture.md](docker-architecture.md). No repo checkout, no
14+`./docker/boxd`, no compose plugin. The CLI is the product; the repo is its
15+source.
16+
17+```
18+curl -fsSL https://tx9.davis7.sh/install | bash
19+tx9 create
20+```
21+
22+## The 12 decisions
23+
24+| # | Question | Decision |
25+|---|---|---|
26+| 1 | Image source | **Embedded assets, local build.** The binary `go:embed`s provision/, guest/, Dockerfile, box.env; first `create` builds the image locally (~10 min, then cached). No registry. |
27+| 2 | State home | **`~/.tx9/`** — `boxes/<name>.env` (token cache), `locks/`, `config.toml`. Docker objects are label-tagged so `tx9 list` reconstructs truth from the daemon even if `~/.tx9` is lost. Backups default to **`~/Downloads`**. |
28+| 3 | Naming + create UX | Friendly generated names (`large-cat` style) by default, `tx9 create my_box` for explicit. `create` ends with a printed numbered checklist (enter, auth claude/codex, hermes setup, dashboard URL). |
29+| 4 | Command surface | See table below. `enter`/`ssh` are aliases; `backup`/`export` are aliases. |
30+| 5 | Password UX | Precedence: `--password` flag → `TX9_PASSWORD` env → interactive hidden prompt. **`--no-encrypt` escape hatch** for quick local snapshots. |
31+| 6 | Archive format | **`.tx9` extension** (tar.gz inside, GPG-wrapped unless --no-encrypt; CLI sniffs which). Embeds box-name metadata; `import` restores under that name, `--name` overrides, collision = hard fail. |
32+| 7 | Scope | **Local-only v1.** The CLI drives the local Docker daemon; cross-machine moves are backup → transfer yourself → import. (Remote is a saved future direction — see below.) |
33+| 8 | Docker interface | **Engine API via the official Go SDK**, not shelling to compose. tx9 creates the network/volumes/containers itself. Kills the compose-plugin dependency (bit us on siva) and owns all progress/error UX. |
34+| 9 | Repo | **This repo.** Go code lives alongside provision/ and guest/ because they co-evolve; embedding pulls from source at build time. |
35+| 10 | Distribution | **GitHub Releases** (cross-compiled, tag-push CI) + **`https://tx9.davis7.sh/install`** (redirect to the raw installer; detects OS/arch, drops binary in `~/.local/bin`). Repo goes public when ready; hosted page/site later. |
36+| 11 | Versioning | `tx9 upgrade` (no args) self-updates the CLI; `tx9 upgrade <box>` moves a box onto the current image. Images tagged `tx9-box:<cli-version>`; `list` shows per-box image version so drift is visible; unused old images pruned. |
37+| 12 | Bash prototypes | **Both deleted** (done, this commit). git history is the reference. |
38+
39+## Command surface
40+
41+| Command | Behavior |
42+|---|---|
43+| `tx9 create [name]` | Generate name if absent. Build `tx9-box:<version>` if missing (with real progress UX). Create network + volumes + both containers, mint token, wire MCP, run doctor. Print getting-started checklist. |
44+| `tx9 list` | All boxes on this machine from daemon labels: state (running/stopped/crashed), image version vs CLI version (drift flag), dashboard URL. |
45+| `tx9 enter <box>` (alias `ssh`) | Exec into the agent container as `agent`, tmux `main` attach. Starts the box if stopped. |
46+| `tx9 start <box>` / `tx9 stop <box>` | Both containers together. Volumes persist. |
47+| `tx9 backup <box>` (alias `export`) | Flags: `--path` (default `~/Downloads`), `--password`/env/prompt, `--no-encrypt`. Quiesce → archive agent /data → validate → (encrypt) → verify → `<box>-<timestamp>.tx9`. |
48+| `tx9 import <file.tx9>` | Flags: `--name`, `--password`/env/prompt. Validate before creating anything; restore staged; arrive quiesced + gateway-disabled + fresh token; fail on name collision. |
49+| `tx9 open <box>` | Print (or open) the authenticated dashboard URL (`?_token=`). |
50+| `tx9 doctor <box>` | In-box `hb doctor` + host-side published-port probe. |
51+| `tx9 upgrade [box]` | No args: self-update (re-run installer logic). With box: recreate containers on current image + readiness gate. |
52+| `tx9 delete <box>` | Containers + volumes + token file, typed-name confirmation (`--force` to skip). |
53+| `tx9 prune` | Remove unused `tx9-box:*` image versions and stale state files. |
54+
55+## Fixed contracts (carried from the verified bash draft)
56+
57+These were dry-run- and QA-verified in the boxd prototype and must survive
58+the port:
59+
60+- **Topology**: agent container (no published ports, volume = the box, sudo,
61+ IPv6 disabled via sysctl) + executor container (daemon only,
62+ `--hostname 0.0.0.0 --port 4788 --auth-token <token>`, one published
63+ 0.0.0.0 host port, scratch volume) + per-box private network. Labels
64+ (`tx9=1`, `tx9.box=<name>`, `tx9.version=<v>`) on every object.
65+- **Token**: 256-bit random per box, minted at create/import, injected as
66+ `BOXD_EXECUTOR_TOKEN` (outranks any restored on-disk `executor-mcp.env` —
67+ real bug found in dry run) and `EXECUTOR_HOST=executor`. `hb wire-once`
68+ re-wires when token *or URL* is stale.
69+- **Archive safety**: the member-by-member validator (absolute paths,
70+ traversal, special files, duplicates, link targets/cycles/nesting) ported
71+ from bash+python to Go. Validate before create on import; validate after
72+ tar on backup. Restores stage-then-promote in a throwaway container and
73+ re-arm quiesce + gateway-disabled markers.
74+- **Gateway single-writer**: restored boxes never auto-enable the Hermes
75+ gateway. `hb gateway-enable --confirm-single-writer …` stays the only path.
76+- **Entrypoints**: agent PID-1 = `hb init` (fail fast) + hb-workload loop
77+ under docker `--init`; executor PID-1 = foreground daemon. Restart policy
78+ `unless-stopped`.
79+- **Gotchas to preserve**: containers need
80+ `net.ipv6.conf.all.disable_ipv6=1` (executor binds `localhost` → `::1`
81+ otherwise); right after start the executor needs a boot-window retry
82+ before health checks pass.
83+
84+## Go implementation notes
85+
86+- `go:embed` a tar of `provision/`, `guest/`, `docker/Dockerfile`,
87+ entrypoints, `box.env` → feed to the Engine API's ImageBuild as the build
88+ context. Image tag from the CLI's ldflags version.
89+- Name generator: small embedded adjective+animal word lists.
90+- Per-box flock in `~/.tx9/locks/` (same semantics as boxd: one operation
91+ per box; fail fast, don't queue).
92+- `create` progress: stream ImageBuild output through a spinner/step UI on
93+ first build; subsequent creates are seconds.
94+- `.tx9` format: tar.gz of `/data` plus a small JSON metadata header
95+ (box name, created-at, CLI/image version, encrypted flag). Simplest
96+ encoding: outer tar with `metadata.json` + `data.tar.gz[.gpg]` members —
97+ sniffable, streamable, and the metadata stays readable without the
98+ passphrase.
99+- Encryption: keep GPG-compatible AES-256 symmetric (shell out to gpg, or
100+ use a Go OpenPGP lib) so operators can decrypt archives without tx9 in an
101+ emergency.
102+- Doctor/MCP checks: port `lib-mcp.sh`'s initialize handshake to Go (proper
103+ HTTP client, jq logic in code).
104+
105+## Saved for later (explicitly out of v1)
106+
107+- **Remote/multi-host** — worth doing eventually: `tx9 --host nexus list`,
108+ or `tx9 move large-cat nexus` (backup → stream → import → gateway gates,
109+ one command from a laptop). Design constraint to keep in mind now: keep
110+ the daemon interface behind a small interface type so a remote Docker
111+ daemon (SSH tunnel / `DOCKER_HOST`) slots in without restructuring.
112+- Hosted docs/site at tx9.davis7.sh beyond the install redirect.
113+- Registry-published images (decision 1 fallback if local builds annoy).
114+- Compose healthchecks / `depends_on: service_healthy` equivalents via the
115+ SDK health config.
116+- Executor-state backup (currently: scratch by contract, does not travel).
117+- Slim executor image variant; token rotation command; executor egress
118+ policy network.
119+
120+## Migration to tx9
121+
122+The guest layer (`guest/`, `provision/`, `box.env`) is shared and already
123+compose-split aware, so existing boxd-created boxes are structurally
124+identical to what tx9 will create — only labels/names differ
125+(`hbl-*` vs `tx9-*`). Don't build compatibility: the running dry boxes are
126+disposable, and any box that matters moves via `backup`/`import` anyway.
go.mod+38−0
@@ -0,0 +1,38 @@
1+module github.com/davis7dotsh/tx9
2+
3+go 1.26
4+
5+require (
6+ github.com/docker/docker v28.5.2+incompatible
7+ github.com/docker/go-connections v0.7.0
8+ golang.org/x/sys v0.46.0
9+)
10+
11+require (
12+ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
13+ github.com/Microsoft/go-winio v0.6.2 // indirect
14+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
15+ github.com/containerd/errdefs v1.0.0 // indirect
16+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
17+ github.com/containerd/log v0.1.0 // indirect
18+ github.com/distribution/reference v0.6.0 // indirect
19+ github.com/docker/go-units v0.5.0 // indirect
20+ github.com/felixge/httpsnoop v1.0.4 // indirect
21+ github.com/go-logr/logr v1.4.3 // indirect
22+ github.com/go-logr/stdr v1.2.2 // indirect
23+ github.com/moby/docker-image-spec v1.3.1 // indirect
24+ github.com/moby/sys/atomicwriter v0.1.0 // indirect
25+ github.com/moby/term v0.5.2 // indirect
26+ github.com/morikuni/aec v1.1.0 // indirect
27+ github.com/opencontainers/go-digest v1.0.0 // indirect
28+ github.com/opencontainers/image-spec v1.1.1 // indirect
29+ github.com/pkg/errors v0.9.1 // indirect
30+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
31+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
32+ go.opentelemetry.io/otel v1.44.0 // indirect
33+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
34+ go.opentelemetry.io/otel/metric v1.44.0 // indirect
35+ go.opentelemetry.io/otel/trace v1.44.0 // indirect
36+ golang.org/x/time v0.15.0 // indirect
37+ gotest.tools/v3 v3.5.2 // indirect
38+)
go.sum+112−0
@@ -0,0 +1,112 @@
1+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
2+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
3+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
4+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
5+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
6+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
7+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
8+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
9+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
10+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
11+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
12+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
13+github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
14+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
15+github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g=
16+github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
17+github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
18+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
19+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
20+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
21+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
22+github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
23+github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
24+github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
25+github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
26+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
27+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
28+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
29+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
30+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
31+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
32+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
33+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
34+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
35+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
36+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
37+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
38+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
39+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
40+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
41+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
42+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
43+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
44+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
45+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
46+github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
47+github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
48+github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
49+github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
50+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
51+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
52+github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
53+github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
54+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
55+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
56+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
57+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
58+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
59+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
60+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
61+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
62+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
63+github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
64+github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
65+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
66+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
67+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
68+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
69+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
70+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
71+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
72+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
73+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
74+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
75+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
76+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
77+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
78+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
79+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
80+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
81+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
82+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
83+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
84+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
85+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
86+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
87+go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
88+go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
89+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
90+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
91+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
92+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
93+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
94+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
95+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
96+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
97+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
98+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
99+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
100+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
101+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
102+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
103+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
104+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
105+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
106+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
107+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
108+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
109+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
110+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
111+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
112+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
guest/hb+49−11
@@ -22,6 +22,15 @@ WIRED="$AGENT_HOME/.hermes-box-wired" · 21 unmodified lines
2222 TOKEN_FILE="$AGENT_HOME/.executor/server-control/auth.json"
2323 TOKEN_ENV="$AGENT_HOME/.config/hermes-box/executor-mcp.env"
2424
25+# Remote-executor mode (Docker compose split): when EXECUTOR_HOST points away
26+# from loopback, Executor runs in its own container. hb then only checks
27+# reachability and wires MCP against it — it never spawns, stops, or reads
28+# token files locally; the runtime supplies EXECUTOR_MCP_TOKEN via the
29+# environment instead.
30+EXECUTOR_HOST="${EXECUTOR_HOST:-127.0.0.1}"
31+_exec_remote() { [[ "$EXECUTOR_HOST" != 127.0.0.1 ]]; }
32+_executor_url() { printf 'http://%s:%s/mcp' "$EXECUTOR_HOST" "${EXECUTOR_PORT:-4788}"; }
33+
2534 init() {
2635 local managed=(
2736 "$AGENT_HOME" "$AGENT_HOME/.claude" "$AGENT_HOME/.codex" "$AGENT_HOME/.hermes"
@@ -44,7 +53,7 @@ init() { · 16 unmodified lines
4453 }
4554
4655 _port_open() {
47− (exec 3<>"/dev/tcp/127.0.0.1/${EXECUTOR_PORT:-4788}") 2>/dev/null && { exec 3>&-; return 0; }
56+ (exec 3<>"/dev/tcp/$EXECUTOR_HOST/${EXECUTOR_PORT:-4788}") 2>/dev/null && { exec 3>&-; return 0; }
4857 return 1
4958 }
5059
@@ -119,6 +128,11 @@ _spawn_without_lock_fd() { · 68 unmodified lines
119128
120129 _executor_up() {
121130 init
131+ if _exec_remote; then
132+ _wait_port open || { echo "remote executor unreachable at $EXECUTOR_HOST:${EXECUTOR_PORT:-4788}" >&2; return 1; }
133+ echo "executor daemon: reachable (remote)"
134+ return 0
135+ fi
122136 command -v executor >/dev/null 2>&1 || {
123137 [[ "${INSTALL_EXECUTOR:-0}" == 1 ]] && { echo "executor not installed" >&2; return 1; }
124138 echo "executor disabled"
@@ -185,6 +199,9 @@ reconcile() { · 60 unmodified lines
185199 }
186200
187201 _stop_executor() {
202+ # Remote executor has its own container lifecycle; quiescing this box must
203+ # not (and cannot) stop it. boxd stops the executor service around saves.
204+ _exec_remote && return 0
188205 command -v executor >/dev/null 2>&1 && executor daemon stop >/dev/null 2>&1 || true
189206 pkill -f "executor daemon run" 2>/dev/null || true
190207 rm -f "$AGENT_HOME/.executor"/daemon-active-* 2>/dev/null || true
@@ -302,8 +319,18 @@ web() { · 111 unmodified lines
302319 }
303320
304321 executor_token() {
305− [[ -r "$TOKEN_FILE" ]] || return 1
306− sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TOKEN_FILE" | head -1
322+ # BOXD_EXECUTOR_TOKEN is the runtime-injected token in the compose split.
323+ # It must win over EXECUTOR_MCP_TOKEN because /etc/profile.d sources the
324+ # on-disk executor-mcp.env, and after a restore that file carries the
325+ # SOURCE box's stale token — clobbering EXECUTOR_MCP_TOKEN and quietly
326+ # wiring MCP against a dead credential.
327+ if [[ -n "${BOXD_EXECUTOR_TOKEN:-}" ]]; then printf '%s\n' "$BOXD_EXECUTOR_TOKEN"; return 0; fi
328+ if [[ -r "$TOKEN_FILE" ]]; then
329+ sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TOKEN_FILE" | head -1
330+ return 0
331+ fi
332+ [[ -n "${EXECUTOR_MCP_TOKEN:-}" ]] && { printf '%s\n' "$EXECUTOR_MCP_TOKEN"; return 0; }
333+ return 1
307334 }
308335
309336 _write_token_env() {
@@ -335,9 +362,10 @@ wire_mcp() { · 25 unmodified lines
335362 rm -f "$WIRED" "$TOKEN_ENV"
336363 return 0
337364 fi
338− command -v executor >/dev/null 2>&1 || { echo "executor not installed" >&2; return 1; }
365+ _exec_remote || command -v executor >/dev/null 2>&1 || { echo "executor not installed" >&2; return 1; }
339366 up >/dev/null
340− local token url="http://127.0.0.1:${EXECUTOR_PORT:-4788}/mcp" failed=0
367+ local token url failed=0
368+ url="$(_executor_url)"
341369 token="$(executor_token)"
342370 [[ -n "$token" ]] || { echo "executor token unavailable" >&2; return 1; }
343371 _write_token_env "$token"
@@ -362,11 +390,16 @@ wire_mcp() { · 18 unmodified lines
362390 }
363391
364392 wire_once() {
365− local token
393+ local token url
366394 if [[ "${WIRE_EXECUTOR_MCP:-0}" != 1 ]]; then wire_mcp; return; fi
367395 token="$(executor_token 2>/dev/null || true)"
396+ url="$(_executor_url)"
397+ # Freshness = version marker + current token in TOKEN_ENV + current URL in
398+ # the codex config. The URL check catches restores that carry wiring from
399+ # a box with a different executor host (e.g. loopback -> compose remote).
368400 if [[ -f "$WIRED" && "$(cat "$WIRED")" == "$WIRE_VERSION" && -r "$TOKEN_ENV" && -n "$token" ]] &&
369− grep -Fq "$token" "$TOKEN_ENV"; then return 0; fi
401+ grep -Fq "$token" "$TOKEN_ENV" &&
402+ grep -qF "url = \"$url\"" "$CODEX_HOME/config.toml" 2>/dev/null; then return 0; fi
370403 wire_mcp
371404 }
372405
@@ -432,13 +465,13 @@ _command_exists() { command -v "$1" >/dev/null 2>&1; } · 59 unmodified lines
432465 _hermes_state_valid() { hermes-state verify >/dev/null; }
433466
434467 _claude_http_config() {
435− (cd "$AGENT_HOME/workspace" && claude mcp get executor 2>/dev/null) | grep -q "http://127.0.0.1:${EXECUTOR_PORT:-4788}/mcp"
468+ (cd "$AGENT_HOME/workspace" && claude mcp get executor 2>/dev/null) | grep -qF "$(_executor_url)"
436469 }
437470
438471 _mcp_auth_works() {
439472 local token
440473 token="$(executor_token)" || return 1
441− _mcp_initialize "http://127.0.0.1:${EXECUTOR_PORT:-4788}/mcp" "$token"
474+ _mcp_initialize "$(_executor_url)" "$token"
442475 }
443476
444477 doctor() {
@@ -453,7 +486,12 @@ doctor() { · 8 unmodified lines
453486 else
454487 printf 'ok Hermes gateway intentionally disabled or quiesced\n'
455488 fi
456− if [[ "${INSTALL_EXECUTOR:-0}" == 1 ]]; then
489+ if _exec_remote; then
490+ # Remote executor keeps running while this box quiesces; only reachability
491+ # and auth are this box's concern.
492+ _check "executor reachable (remote $EXECUTOR_HOST)" _port_open || failed=1
493+ _check "executor MCP bearer accepted" _mcp_auth_works || failed=1
494+ elif [[ "${INSTALL_EXECUTOR:-0}" == 1 ]]; then
457495 _check "executor installed" _command_exists executor || failed=1
458496 if [[ -e "$QUIESCE_FILE" ]]; then
459497 _check "Executor target port closed while quiesced" _port_closed || failed=1
@@ -473,7 +511,7 @@ doctor() { · 13 unmodified lines
473511 _check "MCP wiring version" test "$(cat "$WIRED" 2>/dev/null)" = "$WIRE_VERSION" || failed=1
474512 _check "MCP token environment" test -r "$TOKEN_ENV" || failed=1
475513 _check "Claude HTTP MCP config" _claude_http_config || failed=1
476− _check "Codex HTTP MCP config" grep -q "url = \"http://127.0.0.1:${EXECUTOR_PORT:-4788}/mcp\"" "$CODEX_HOME/config.toml" || failed=1
514+ _check "Codex HTTP MCP config" grep -qF "url = \"$(_executor_url)\"" "$CODEX_HOME/config.toml" || failed=1
477515 fi
478516 [[ "$failed" == 0 ]]
479517 }
internal/archive/format.go+201−0
@@ -0,0 +1,201 @@
1+// Package archive implements the .tx9 backup container format: a small,
2+// pure-Go, docker-free library used by `tx9 backup`/`tx9 import`
3+// (internal/cli) for building, reading, and validating archives. Nothing
4+// here talks to Docker — callers hand it files/streams and get files/
5+// streams back, so it's independently testable and reusable if a future
6+// remote-move feature (docs/tx9-cli-design.md "saved for later") needs the
7+// same format without a live daemon.
8+//
9+// Container shape (docs/tx9-cli-design.md decision 6): a `.tx9` file is an
10+// uncompressed outer tar with exactly two members, in order:
11+//
12+// 1. metadata.json — always present, always plaintext, so `import` can
13+// read the target box name and whether a password prompt is needed
14+// without touching the (possibly encrypted) payload.
15+// 2. data.tar.gz or data.tar.gz.gpg — the actual box data, gzip-compressed
16+// (and optionally GPG-symmetric-encrypted on top). The outer tar is
17+// deliberately uncompressed: this member is already compressed (and
18+// possibly encrypted, i.e. incompressible), so wrapping it in another
19+// compression pass would only burn CPU.
20+package archive
21+
22+import (
23+ "archive/tar"
24+ "encoding/json"
25+ "fmt"
26+ "io"
27+ "os"
28+ "time"
29+)
30+
31+// FormatVersion is the current .tx9 container format version, written into
32+// every archive's metadata.json and checked on read so a future incompatible
33+// format change fails loudly instead of silently misparsing.
34+const FormatVersion = 1
35+
36+// Tar member names inside a .tx9 container, in fixed order.
37+const (
38+ metadataMember = "metadata.json"
39+ dataMemberPlain = "data.tar.gz"
40+ dataMemberEncrypt = "data.tar.gz.gpg"
41+)
42+
43+// Metadata is the JSON header every .tx9 archive carries as its first tar
44+// member.
45+type Metadata struct {
46+ BoxName string `json:"box_name"`
47+ CreatedAt time.Time `json:"created_at"`
48+ CLIVersion string `json:"cli_version"`
49+ ImageVersion string `json:"image_version"`
50+ Encrypted bool `json:"encrypted"`
51+ FormatVersion int `json:"format_version"`
52+}
53+
54+// dataMemberName returns the name the data member must have for a given
55+// Encrypted flag.
56+func dataMemberName(encrypted bool) string {
57+ if encrypted {
58+ return dataMemberEncrypt
59+ }
60+ return dataMemberPlain
61+}
62+
63+// WriteTx9 writes a complete .tx9 container to w: metadata.json first, then
64+// the verbatim contents of dataFile as the second member (named
65+// data.tar.gz or data.tar.gz.gpg per meta.Encrypted). meta.FormatVersion is
66+// overwritten with the current FormatVersion regardless of what the caller
67+// set.
68+func WriteTx9(w io.Writer, meta Metadata, dataFile string) error {
69+ meta.FormatVersion = FormatVersion
70+
71+ metaBytes, err := json.MarshalIndent(meta, "", " ")
72+ if err != nil {
73+ return fmt.Errorf("archive: marshal metadata: %w", err)
74+ }
75+
76+ df, err := os.Open(dataFile)
77+ if err != nil {
78+ return fmt.Errorf("archive: open data file %s: %w", dataFile, err)
79+ }
80+ defer df.Close()
81+ info, err := df.Stat()
82+ if err != nil {
83+ return fmt.Errorf("archive: stat data file %s: %w", dataFile, err)
84+ }
85+
86+ now := time.Now()
87+ tw := tar.NewWriter(w)
88+
89+ if err := tw.WriteHeader(&tar.Header{
90+ Typeflag: tar.TypeReg,
91+ Name: metadataMember,
92+ Mode: 0600,
93+ Size: int64(len(metaBytes)),
94+ ModTime: now,
95+ }); err != nil {
96+ return fmt.Errorf("archive: write metadata header: %w", err)
97+ }
98+ if _, err := tw.Write(metaBytes); err != nil {
99+ return fmt.Errorf("archive: write metadata: %w", err)
100+ }
101+
102+ if err := tw.WriteHeader(&tar.Header{
103+ Typeflag: tar.TypeReg,
104+ Name: dataMemberName(meta.Encrypted),
105+ Mode: 0600,
106+ Size: info.Size(),
107+ ModTime: info.ModTime(),
108+ }); err != nil {
109+ return fmt.Errorf("archive: write data header: %w", err)
110+ }
111+ if _, err := io.Copy(tw, df); err != nil {
112+ return fmt.Errorf("archive: write data: %w", err)
113+ }
114+
115+ if err := tw.Close(); err != nil {
116+ return fmt.Errorf("archive: close container: %w", err)
117+ }
118+ return nil
119+}
120+
121+// readMetadataMember reads and decodes the first tar member of path,
122+// erroring clearly if path doesn't look like a .tx9 container. It returns
123+// the open file positioned right after the metadata member (so
124+// ExtractData can continue reading the data member from the same reader)
125+// along with the decoded metadata.
126+func readMetadataMember(f *os.File) (Metadata, *tar.Reader, error) {
127+ tr := tar.NewReader(f)
128+ hdr, err := tr.Next()
129+ if err != nil {
130+ return Metadata{}, nil, fmt.Errorf("does not look like a .tx9 file (reading first member): %w", err)
131+ }
132+ if hdr.Name != metadataMember {
133+ return Metadata{}, nil, fmt.Errorf("does not look like a .tx9 file (expected first member %q, got %q)", metadataMember, hdr.Name)
134+ }
135+
136+ var meta Metadata
137+ if err := json.NewDecoder(tr).Decode(&meta); err != nil {
138+ return Metadata{}, nil, fmt.Errorf("invalid metadata.json: %w", err)
139+ }
140+ if meta.FormatVersion == 0 {
141+ return Metadata{}, nil, fmt.Errorf("invalid metadata.json: missing format_version")
142+ }
143+ if meta.FormatVersion > FormatVersion {
144+ return Metadata{}, nil, fmt.Errorf("archive format version %d is newer than this tx9 (supports up to %d) — upgrade tx9 first", meta.FormatVersion, FormatVersion)
145+ }
146+ return meta, tr, nil
147+}
148+
149+// ReadMetadata reads only the first member of the .tx9 file at path and
150+// decodes it as Metadata, never touching the (possibly encrypted) second
151+// member. This is what lets `import` discover the target box name and
152+// whether a password prompt is needed before doing any real work.
153+func ReadMetadata(path string) (Metadata, error) {
154+ f, err := os.Open(path)
155+ if err != nil {
156+ return Metadata{}, fmt.Errorf("archive: open %s: %w", path, err)
157+ }
158+ defer f.Close()
159+
160+ meta, _, err := readMetadataMember(f)
161+ if err != nil {
162+ return Metadata{}, fmt.Errorf("archive: %s %w", path, err)
163+ }
164+ return meta, nil
165+}
166+
167+// ExtractData reads the .tx9 file at path and writes its second tar member
168+// (the data payload — gzip tar, or GPG-wrapped gzip tar if the metadata
169+// says Encrypted) verbatim to destFile. Returns the metadata read along the
170+// way, so callers don't need a separate ReadMetadata call.
171+func ExtractData(path, destFile string) (Metadata, error) {
172+ f, err := os.Open(path)
173+ if err != nil {
174+ return Metadata{}, fmt.Errorf("archive: open %s: %w", path, err)
175+ }
176+ defer f.Close()
177+
178+ meta, tr, err := readMetadataMember(f)
179+ if err != nil {
180+ return Metadata{}, fmt.Errorf("archive: %s %w", path, err)
181+ }
182+
183+ hdr, err := tr.Next()
184+ if err != nil {
185+ return Metadata{}, fmt.Errorf("archive: %s: missing data member: %w", path, err)
186+ }
187+ wantName := dataMemberName(meta.Encrypted)
188+ if hdr.Name != wantName {
189+ return Metadata{}, fmt.Errorf("archive: %s: expected data member %q, got %q", path, wantName, hdr.Name)
190+ }
191+
192+ out, err := os.OpenFile(destFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
193+ if err != nil {
194+ return Metadata{}, fmt.Errorf("archive: create %s: %w", destFile, err)
195+ }
196+ defer out.Close()
197+ if _, err := io.Copy(out, tr); err != nil {
198+ return Metadata{}, fmt.Errorf("archive: extract data from %s: %w", path, err)
199+ }
200+ return meta, nil
201+}
internal/archive/format_test.go+170−0
@@ -0,0 +1,170 @@
1+package archive
2+
3+import (
4+ "archive/tar"
5+ "bytes"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+ "time"
11+)
12+
13+func tarWriterForTest(t *testing.T, w io.Writer) *tar.Writer {
14+ t.Helper()
15+ return tar.NewWriter(w)
16+}
17+
18+func writeTestTarMember(t *testing.T, tw *tar.Writer, name string, body []byte) {
19+ t.Helper()
20+ if err := tw.WriteHeader(&tar.Header{
21+ Typeflag: tar.TypeReg,
22+ Name: name,
23+ Mode: 0600,
24+ Size: int64(len(body)),
25+ }); err != nil {
26+ t.Fatalf("write header %q: %v", name, err)
27+ }
28+ if _, err := tw.Write(body); err != nil {
29+ t.Fatalf("write body %q: %v", name, err)
30+ }
31+}
32+
33+func TestWriteTx9RoundTrip(t *testing.T) {
34+ dir := t.TempDir()
35+ dataFile := filepath.Join(dir, "data.tar.gz")
36+ payload := []byte("fake gzip tar payload")
37+ if err := os.WriteFile(dataFile, payload, 0600); err != nil {
38+ t.Fatalf("write data file: %v", err)
39+ }
40+
41+ meta := Metadata{
42+ BoxName: "large-cat",
43+ CreatedAt: time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC),
44+ CLIVersion: "dev",
45+ ImageVersion: "dev",
46+ Encrypted: false,
47+ }
48+
49+ tx9Path := filepath.Join(dir, "archive.tx9")
50+ out, err := os.Create(tx9Path)
51+ if err != nil {
52+ t.Fatalf("create tx9: %v", err)
53+ }
54+ if err := WriteTx9(out, meta, dataFile); err != nil {
55+ t.Fatalf("WriteTx9: %v", err)
56+ }
57+ if err := out.Close(); err != nil {
58+ t.Fatalf("close tx9: %v", err)
59+ }
60+
61+ gotMeta, err := ReadMetadata(tx9Path)
62+ if err != nil {
63+ t.Fatalf("ReadMetadata: %v", err)
64+ }
65+ if gotMeta.BoxName != meta.BoxName || gotMeta.FormatVersion != FormatVersion {
66+ t.Fatalf("metadata mismatch: got %+v", gotMeta)
67+ }
68+ if gotMeta.Encrypted {
69+ t.Fatal("expected Encrypted=false")
70+ }
71+
72+ extracted := filepath.Join(dir, "extracted.tar.gz")
73+ extractedMeta, err := ExtractData(tx9Path, extracted)
74+ if err != nil {
75+ t.Fatalf("ExtractData: %v", err)
76+ }
77+ if extractedMeta.BoxName != meta.BoxName {
78+ t.Fatalf("extracted metadata mismatch: got %+v", extractedMeta)
79+ }
80+
81+ got, err := os.ReadFile(extracted)
82+ if err != nil {
83+ t.Fatalf("read extracted data: %v", err)
84+ }
85+ if !bytes.Equal(got, payload) {
86+ t.Fatalf("extracted data mismatch: got %q, want %q", got, payload)
87+ }
88+}
89+
90+func TestWriteTx9EncryptedMemberName(t *testing.T) {
91+ dir := t.TempDir()
92+ dataFile := filepath.Join(dir, "data.tar.gz.gpg")
93+ if err := os.WriteFile(dataFile, []byte("ciphertext"), 0600); err != nil {
94+ t.Fatalf("write data file: %v", err)
95+ }
96+
97+ meta := Metadata{BoxName: "x", Encrypted: true}
98+ tx9Path := filepath.Join(dir, "archive.tx9")
99+ out, err := os.Create(tx9Path)
100+ if err != nil {
101+ t.Fatalf("create: %v", err)
102+ }
103+ if err := WriteTx9(out, meta, dataFile); err != nil {
104+ t.Fatalf("WriteTx9: %v", err)
105+ }
106+ if err := out.Close(); err != nil {
107+ t.Fatalf("close: %v", err)
108+ }
109+
110+ gotMeta, err := ReadMetadata(tx9Path)
111+ if err != nil {
112+ t.Fatalf("ReadMetadata: %v", err)
113+ }
114+ if !gotMeta.Encrypted {
115+ t.Fatal("expected Encrypted=true")
116+ }
117+
118+ extracted := filepath.Join(dir, "extracted.gpg")
119+ if _, err := ExtractData(tx9Path, extracted); err != nil {
120+ t.Fatalf("ExtractData: %v", err)
121+ }
122+ got, err := os.ReadFile(extracted)
123+ if err != nil {
124+ t.Fatalf("read extracted: %v", err)
125+ }
126+ if string(got) != "ciphertext" {
127+ t.Fatalf("got %q", got)
128+ }
129+}
130+
131+func TestReadMetadata_NotATx9File(t *testing.T) {
132+ dir := t.TempDir()
133+ junk := filepath.Join(dir, "junk.tx9")
134+ if err := os.WriteFile(junk, []byte("not a tar file at all"), 0600); err != nil {
135+ t.Fatalf("write junk: %v", err)
136+ }
137+ if _, err := ReadMetadata(junk); err == nil {
138+ t.Fatal("expected error reading metadata from non-tar file")
139+ }
140+}
141+
142+// TestReadMetadata_FutureFormatVersion builds a .tx9 container by hand
143+// (bypassing WriteTx9, which always forces the current FormatVersion) to
144+// confirm readMetadataMember's version gate rejects an archive from a
145+// hypothetical newer tx9 instead of silently misparsing it.
146+func TestReadMetadata_FutureFormatVersion(t *testing.T) {
147+ dir := t.TempDir()
148+ tx9Path := filepath.Join(dir, "archive.tx9")
149+
150+ metaJSON := []byte(`{"box_name":"x","format_version":999}`)
151+ dataBody := []byte("irrelevant")
152+
153+ f, err := os.Create(tx9Path)
154+ if err != nil {
155+ t.Fatalf("create: %v", err)
156+ }
157+ tw := tarWriterForTest(t, f)
158+ writeTestTarMember(t, tw, metadataMember, metaJSON)
159+ writeTestTarMember(t, tw, dataMemberName(false), dataBody)
160+ if err := tw.Close(); err != nil {
161+ t.Fatalf("close tar: %v", err)
162+ }
163+ if err := f.Close(); err != nil {
164+ t.Fatalf("close file: %v", err)
165+ }
166+
167+ if _, err := ReadMetadata(tx9Path); err == nil {
168+ t.Fatal("expected error for a future format_version")
169+ }
170+}
internal/archive/gpg.go+211−0
@@ -0,0 +1,211 @@
1+package archive
2+
3+import (
4+ "archive/tar"
5+ "bytes"
6+ "compress/gzip"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "strings"
13+)
14+
15+// runGPG shells out to gpg (no Go dependency — decision: replicate boxd's
16+// approach exactly, dossier §6.4). The passphrase is delivered on fd 3 via
17+// an *os.Pipe handed to the child as ExtraFiles[0] (which os/exec always
18+// places at fd 3, since 0/1/2 are stdin/stdout/stderr), matching boxd's
19+// `3<<<"$_BOX_PASS"` here-string — never a CLI argument or a plain
20+// GPG_PASSPHRASE-style env var, both of which would leak via `ps` or
21+// /proc/<pid>/environ.
22+func runGPG(passphrase string, args []string, stdin io.Reader, stdout io.Writer) error {
23+ gpgPath, err := exec.LookPath("gpg")
24+ if err != nil {
25+ return fmt.Errorf("gpg not found in PATH (required for encrypted archives; install gnupg, or pass --no-encrypt): %w", err)
26+ }
27+
28+ pr, pw, err := os.Pipe()
29+ if err != nil {
30+ return fmt.Errorf("create passphrase pipe: %w", err)
31+ }
32+
33+ cmd := exec.Command(gpgPath, args...)
34+ cmd.ExtraFiles = []*os.File{pr}
35+ cmd.Stdin = stdin
36+ cmd.Stdout = stdout
37+ var stderrBuf bytes.Buffer
38+ cmd.Stderr = &stderrBuf
39+
40+ if err := cmd.Start(); err != nil {
41+ pr.Close()
42+ pw.Close()
43+ return fmt.Errorf("start gpg: %w", err)
44+ }
45+ pr.Close() // child has its own dup; parent's copy isn't needed once started
46+
47+ if _, werr := io.WriteString(pw, passphrase+"\n"); werr != nil {
48+ pw.Close()
49+ cmd.Wait()
50+ return fmt.Errorf("write passphrase to gpg: %w", werr)
51+ }
52+ pw.Close()
53+
54+ if err := cmd.Wait(); err != nil {
55+ return fmt.Errorf("gpg failed: %w: %s", err, strings.TrimSpace(stderrBuf.String()))
56+ }
57+ return nil
58+}
59+
60+// gpgArgsEncrypt/gpgArgsDecrypt are exactly boxd's gpg_enc/gpg_dec flag
61+// sets (dossier §6.4): GPG-compatible AES-256 symmetric encryption, batch
62+// mode (never blocks on a pinentry prompt), passphrase on fd 3.
63+func gpgArgsEncrypt(dst string) []string {
64+ return []string{
65+ "--batch", "--yes", "--pinentry-mode", "loopback", "--passphrase-fd", "3",
66+ "--symmetric", "--cipher-algo", "AES256", "-o", dst,
67+ }
68+}
69+
70+func gpgArgsDecrypt(src string) []string {
71+ return []string{
72+ "--batch", "--yes", "--pinentry-mode", "loopback", "--passphrase-fd", "3",
73+ "-d", src,
74+ }
75+}
76+
77+// Encrypt GPG-symmetric-encrypts src (read as gpg's stdin, matching boxd's
78+// `gpg_enc "$encrypted" <"$raw"`) to dst using passphrase.
79+func Encrypt(src, dst, passphrase string) error {
80+ in, err := os.Open(src)
81+ if err != nil {
82+ return fmt.Errorf("archive: open %s for encryption: %w", src, err)
83+ }
84+ defer in.Close()
85+
86+ if err := runGPG(passphrase, gpgArgsEncrypt(dst), in, nil); err != nil {
87+ return fmt.Errorf("archive: encrypt %s: %w", src, err)
88+ }
89+ return nil
90+}
91+
92+// DecryptStream decrypts src (a GPG-symmetric-encrypted file) and streams
93+// the plaintext to w, never touching disk for the cleartext — used by
94+// VerifyEncrypted (dossier §6.5) and by Decrypt below.
95+func DecryptStream(src, passphrase string, w io.Writer) error {
96+ if err := runGPG(passphrase, gpgArgsDecrypt(src), nil, w); err != nil {
97+ return fmt.Errorf("archive: decrypt %s: %w", src, err)
98+ }
99+ return nil
100+}
101+
102+// Decrypt GPG-symmetric-decrypts src to dst using passphrase.
103+func Decrypt(src, dst, passphrase string) error {
104+ out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
105+ if err != nil {
106+ return fmt.Errorf("archive: create %s for decryption: %w", dst, err)
107+ }
108+ defer out.Close()
109+ return DecryptStream(src, passphrase, out)
110+}
111+
112+// IsGPG sniffs whether the file at path is GPG binary output rather than a
113+// plain data.tar.gz. Neither boxd nor tx9 ever pass --armor, so ciphertext
114+// is always binary OpenPGP with the packet-format high bit (0x80) set on
115+// its first byte — unambiguous against a gzip stream, whose first byte
116+// (0x1f) does not have that bit set.
117+func IsGPG(path string) bool {
118+ f, err := os.Open(path)
119+ if err != nil {
120+ return false
121+ }
122+ defer f.Close()
123+ var b [1]byte
124+ if _, err := f.Read(b[:]); err != nil {
125+ return false
126+ }
127+ return b[0]&0x80 != 0
128+}
129+
130+// VerifyEncrypted replicates boxd's post-encrypt verification (dossier
131+// §6.5): decrypt encPath into a pipe and list the resulting tar's contents,
132+// so a round-trip failure (wrong passphrase, truncated write, corrupt gpg
133+// output) is caught before the archive is committed to its final path —
134+// without ever writing cleartext to disk.
135+func VerifyEncrypted(encPath, passphrase string) error {
136+ pr, pw := io.Pipe()
137+
138+ decErrCh := make(chan error, 1)
139+ go func() {
140+ decErrCh <- DecryptStream(encPath, passphrase, pw)
141+ pw.Close()
142+ }()
143+
144+ listErr := listTarGz(pr)
145+ pr.Close()
146+ decErr := <-decErrCh
147+
148+ // When listTarGz bails out early (e.g. the decrypted stream isn't a
149+ // valid gzip/tar at all), it stops reading from pr while the decrypt
150+ // goroutine above may still be mid-write into pw. Our own pr.Close()
151+ // then unblocks that pending write with an io.ErrClosedPipe (surfaced
152+ // through DecryptStream's %w-wrapped chain, ultimately from the
153+ // os/exec-internal copy goroutine that services a non-*os.File
154+ // cmd.Stdout). That's an artifact of us walking away early — not
155+ // evidence of a wrong passphrase — so it must never be reported as
156+ // such; the real diagnosis in that case is listErr's tar.gz
157+ // classification failure.
158+ if decErr != nil && !isClosedPipeErr(decErr) {
159+ return fmt.Errorf("archive: verify %s: decrypt failed (wrong passphrase?): %w", encPath, decErr)
160+ }
161+ if listErr != nil {
162+ return fmt.Errorf("archive: verify %s: decrypted data is not a valid tar.gz: %w", encPath, listErr)
163+ }
164+ if decErr != nil {
165+ return fmt.Errorf("archive: verify %s: decrypt failed (wrong passphrase?): %w", encPath, decErr)
166+ }
167+ return nil
168+}
169+
170+// isClosedPipeErr reports whether err is (or wraps) io.ErrClosedPipe.
171+// errors.Is normally suffices here since every layer between the os/exec
172+// copy goroutine and this function wraps with %w, but we also fall back to
173+// a plain string match in case some exec-plumbing path ever flattens the
174+// error into text instead of preserving the sentinel.
175+func isClosedPipeErr(err error) bool {
176+ if err == nil {
177+ return false
178+ }
179+ return errors.Is(err, io.ErrClosedPipe) || strings.Contains(err.Error(), "closed pipe")
180+}
181+
182+// listTarGz reads through every entry of a gzip-compressed tar stream
183+// without extracting anything, erroring on the first corrupt/truncated
184+// entry.
185+func listTarGz(r io.Reader) error {
186+ gz, err := gzip.NewReader(r)
187+ if err != nil {
188+ return err
189+ }
190+ tr := tar.NewReader(gz)
191+ for {
192+ if _, err := tr.Next(); err != nil {
193+ if err != io.EOF {
194+ gz.Close()
195+ return err
196+ }
197+ break
198+ }
199+ }
200+ // tar.Next() returning io.EOF only means the tar reader saw its own
201+ // end-of-archive marker; it does not by itself force the underlying
202+ // gzip stream to be read to its own end, so a corrupt trailing
203+ // CRC32/ISIZE footer would otherwise go unnoticed. Drain the rest of
204+ // the gzip stream and check gzip.Reader.Close (which validates that
205+ // footer) so a corrupt one is still caught here.
206+ if _, err := io.Copy(io.Discard, gz); err != nil {
207+ gz.Close()
208+ return err
209+ }
210+ return gz.Close()
211+}
internal/archive/gpg_test.go+125−0
@@ -0,0 +1,125 @@
1+package archive
2+
3+import (
4+ "bytes"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+func requireGPG(t *testing.T) {
13+ t.Helper()
14+ if _, err := exec.LookPath("gpg"); err != nil {
15+ t.Skip("gpg not installed, skipping encryption test")
16+ }
17+}
18+
19+func TestEncryptDecryptRoundTrip(t *testing.T) {
20+ requireGPG(t)
21+
22+ dir := t.TempDir()
23+ src := filepath.Join(dir, "plain.bin")
24+ enc := filepath.Join(dir, "cipher.gpg")
25+ dec := filepath.Join(dir, "roundtrip.bin")
26+
27+ want := []byte("tx9 archive encryption round-trip test payload")
28+ if err := os.WriteFile(src, want, 0600); err != nil {
29+ t.Fatalf("write plaintext: %v", err)
30+ }
31+
32+ if err := Encrypt(src, enc, "correct horse battery staple"); err != nil {
33+ t.Fatalf("encrypt: %v", err)
34+ }
35+
36+ if !IsGPG(enc) {
37+ t.Fatal("expected encrypted output to sniff as GPG")
38+ }
39+ if IsGPG(src) {
40+ t.Fatal("expected plaintext not to sniff as GPG")
41+ }
42+
43+ if err := Decrypt(enc, dec, "correct horse battery staple"); err != nil {
44+ t.Fatalf("decrypt: %v", err)
45+ }
46+
47+ got, err := os.ReadFile(dec)
48+ if err != nil {
49+ t.Fatalf("read decrypted: %v", err)
50+ }
51+ if !bytes.Equal(got, want) {
52+ t.Fatalf("round-trip mismatch: got %q, want %q", got, want)
53+ }
54+
55+ if err := Decrypt(enc, filepath.Join(dir, "wrong.bin"), "not the passphrase"); err == nil {
56+ t.Fatal("expected decrypt with wrong passphrase to fail")
57+ }
58+}
59+
60+func TestVerifyEncrypted(t *testing.T) {
61+ requireGPG(t)
62+
63+ dir := t.TempDir()
64+ src := filepath.Join(dir, "data.tar.gz")
65+ enc := filepath.Join(dir, "data.tar.gz.gpg")
66+
67+ entries := append(validBase(), file("home/agent/foo.txt", "hello"))
68+ data := buildTarGz(t, entries)
69+ if err := os.WriteFile(src, data, 0600); err != nil {
70+ t.Fatalf("write plaintext tar.gz: %v", err)
71+ }
72+
73+ if err := Encrypt(src, enc, "s3cret"); err != nil {
74+ t.Fatalf("encrypt: %v", err)
75+ }
76+
77+ if err := VerifyEncrypted(enc, "s3cret"); err != nil {
78+ t.Fatalf("verify with correct passphrase: %v", err)
79+ }
80+ if err := VerifyEncrypted(enc, "wrong"); err == nil {
81+ t.Fatal("expected verify with wrong passphrase to fail")
82+ }
83+}
84+
85+// TestVerifyEncrypted_NotTarGz guards against the wrong-passphrase
86+// misclassification bug: when the decrypted plaintext isn't a valid
87+// tar.gz at all, VerifyEncrypted's own early exit from listTarGz closes
88+// the pipe out from under the still-writing decrypt goroutine, which used
89+// to surface as an io.ErrClosedPipe reported as "wrong passphrase?" even
90+// though the CORRECT passphrase was used.
91+func TestVerifyEncrypted_NotTarGz(t *testing.T) {
92+ requireGPG(t)
93+
94+ dir := t.TempDir()
95+ src := filepath.Join(dir, "not-a-tar.bin")
96+ enc := filepath.Join(dir, "not-a-tar.bin.gpg")
97+
98+ // Deliberately not a valid gzip/tar stream, and large enough that gpg
99+ // is still writing plaintext when listTarGz gives up on the gzip
100+ // header.
101+ if err := os.WriteFile(src, []byte(strings.Repeat("not a tar.gz stream ", 4096)), 0600); err != nil {
102+ t.Fatalf("write plaintext: %v", err)
103+ }
104+
105+ if err := Encrypt(src, enc, "s3cret"); err != nil {
106+ t.Fatalf("encrypt: %v", err)
107+ }
108+
109+ err := VerifyEncrypted(enc, "s3cret")
110+ if err == nil {
111+ t.Fatal("expected verify of a non-tar.gz payload to fail")
112+ }
113+ if !strings.Contains(err.Error(), "not a valid tar.gz") {
114+ t.Fatalf("expected a tar.gz classification error, got: %v", err)
115+ }
116+ if strings.Contains(err.Error(), "wrong passphrase") {
117+ t.Fatalf("misreported as wrong passphrase despite using the correct one: %v", err)
118+ }
119+}
120+
121+func TestIsGPG_NotFound(t *testing.T) {
122+ if IsGPG(filepath.Join(t.TempDir(), "does-not-exist")) {
123+ t.Fatal("expected IsGPG to return false for a missing file")
124+ }
125+}
internal/archive/validator.go+402−0
@@ -0,0 +1,402 @@
1+package archive
2+
3+import (
4+ "archive/tar"
5+ "compress/gzip"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "strings"
10+)
11+
12+// ValidateDataTar is a check-for-check Go port of boxd's `_validate_archive`
13+// Python validator (dossier §6.3 — "the production implementation from
14+// ./box, lifted verbatim"). r must be a gzip-compressed tar stream (a
15+// decrypted data.tar.gz, or the plaintext one before encryption). It is the
16+// safety core of both backup (called after the tar is built, before
17+// encryption) and import (called on the decrypted payload, before anything
18+// is extracted onto a real volume) — do not weaken any check here without
19+// re-reading dossier §6.3 rule-by-rule.
20+//
21+// It returns the first violation found, matching the Python version's
22+// fail-fast, single-error behavior (the original never accumulates
23+// multiple errors; it raises immediately at first fault).
24+
25+// maxArchiveMembers hard-caps how many tar entries ValidateDataTar will
26+// read before erroring out, so a crafted archive with millions of tiny
27+// entries can't exhaust memory before validation even gets a chance to
28+// reject it. It's a var (not a const) so tests can lower it without having
29+// to generate a million-entry archive.
30+var maxArchiveMembers = 1_000_000
31+
32+func ValidateDataTar(r io.Reader) error {
33+ gz, err := gzip.NewReader(r)
34+ if err != nil {
35+ return fmt.Errorf("archive is not a valid gzip stream: %w", err)
36+ }
37+
38+ tr := tar.NewReader(gz)
39+
40+ // members = archive.getmembers() — the Python version reads the whole
41+ // member list eagerly before doing anything else.
42+ var headers []*tar.Header
43+ for {
44+ hdr, err := tr.Next()
45+ if err == io.EOF {
46+ break
47+ }
48+ if err != nil {
49+ gz.Close()
50+ return fmt.Errorf("archive is corrupt: %w", err)
51+ }
52+ headers = append(headers, hdr)
53+ // A crafted archive with millions of tiny entries would otherwise
54+ // exhaust memory (the entire header slice is buffered) before any
55+ // of the checks below ever run. maxArchiveMembers is a var, not a
56+ // const, so tests can lower it without generating a
57+ // million-entry archive.
58+ if len(headers) > maxArchiveMembers {
59+ gz.Close()
60+ return fmt.Errorf("archive contains more than %d members", maxArchiveMembers)
61+ }
62+ }
63+ // tar.Next() returning io.EOF only means the tar reader saw its own
64+ // end-of-archive marker; it does not by itself force the underlying
65+ // gzip stream to be read to its own end, so a corrupt trailing
66+ // CRC32/ISIZE footer would otherwise go unnoticed. Drain the rest of
67+ // the gzip stream and check gzip.Reader.Close (which validates that
68+ // footer) so a corrupt one is still caught here.
69+ if _, err := io.Copy(io.Discard, gz); err != nil {
70+ gz.Close()
71+ return fmt.Errorf("archive is corrupt: %w", err)
72+ }
73+ if err := gz.Close(); err != nil {
74+ return fmt.Errorf("archive is corrupt: %w", err)
75+ }
76+ if len(headers) == 0 {
77+ return errors.New("archive is empty")
78+ }
79+
80+ idx := &memberIndex{byName: map[string]*tarMember{}}
81+
82+ // First pass: per-member checks, building the ordered by_name index.
83+ for _, hdr := range headers {
84+ if err := safeText(hdr.Name, "archive path"); err != nil {
85+ return err
86+ }
87+
88+ absolute := strings.HasPrefix(hdr.Name, "/")
89+ rawParts := posixPartsRaw(hdr.Name)
90+ for _, p := range rawParts {
91+ if p == ".." {
92+ return errors.New("archive path escapes its extraction root")
93+ }
94+ }
95+ if absolute {
96+ return errors.New("archive path escapes its extraction root")
97+ }
98+
99+ var parts []string
100+ for _, p := range rawParts {
101+ if p != "." {
102+ parts = append(parts, p)
103+ }
104+ }
105+ key := strings.Join(parts, "/")
106+
107+ if _, exists := idx.byName[key]; exists {
108+ return fmt.Errorf("archive contains duplicate path: %s", displayKey(key))
109+ }
110+
111+ m := &tarMember{Name: key, Linkname: hdr.Linkname}
112+ switch hdr.Typeflag {
113+ case tar.TypeReg, tar.TypeRegA:
114+ m.IsFile = true
115+ case tar.TypeDir:
116+ m.IsDir = true
117+ case tar.TypeSymlink:
118+ m.IsSymlink = true
119+ case tar.TypeLink:
120+ m.IsHardlink = true
121+ default:
122+ return errors.New("archive contains a device, FIFO, socket, or unknown special file")
123+ }
124+
125+ if key == "" && !m.IsDir {
126+ return errors.New("archive root member must be a real directory")
127+ }
128+
129+ if m.IsSymlink || m.IsHardlink {
130+ if err := validateLinkTarget(m, key); err != nil {
131+ return err
132+ }
133+ }
134+
135+ idx.order = append(idx.order, key)
136+ idx.byName[key] = m
137+ }
138+
139+ // Second pass: no member may be nested beneath a link ancestor.
140+ for _, key := range idx.order {
141+ var parts []string
142+ if key != "" {
143+ parts = strings.Split(key, "/")
144+ }
145+ for length := 1; length < len(parts); length++ {
146+ parentKey := strings.Join(parts[:length], "/")
147+ if parent, ok := idx.byName[parentKey]; ok {
148+ if parent.IsSymlink || parent.IsHardlink {
149+ return fmt.Errorf("archive member is nested beneath a link: %s", key)
150+ }
151+ }
152+ }
153+ }
154+
155+ // home and home/agent must exist as real (non-link) directories.
156+ for _, required := range []string{"home", "home/agent"} {
157+ entry, ok := idx.byName[required]
158+ if !ok || !entry.IsDir || entry.IsSymlink || entry.IsHardlink {
159+ return fmt.Errorf("%s must be a real directory", required)
160+ }
161+ }
162+
163+ // Third pass: every member's parent must resolve to a directory, and
164+ // hardlinks must resolve to a regular file.
165+ for _, key := range idx.order {
166+ if key == "" {
167+ continue
168+ }
169+ parent := ""
170+ if i := strings.LastIndex(key, "/"); i >= 0 {
171+ parent = key[:i]
172+ }
173+ if parent != "" {
174+ parentEntry, err := idx.resolve(parent)
175+ if err != nil {
176+ return err
177+ }
178+ if !parentEntry.IsDir {
179+ return fmt.Errorf("archive member parent is not a directory: %s", key)
180+ }
181+ }
182+ member := idx.byName[key]
183+ if member.IsSymlink || member.IsHardlink {
184+ target, err := idx.resolve(key)
185+ if err != nil {
186+ return err
187+ }
188+ if member.IsHardlink && !target.IsFile {
189+ return errors.New("archive hardlink does not resolve to a regular file")
190+ }
191+ }
192+ }
193+
194+ return nil
195+}
196+
197+// tarMember is the subset of a tar.Header ValidateDataTar's rules care
198+// about, keyed by its normalized path (see the key computation above).
199+type tarMember struct {
200+ Name string
201+ IsDir bool
202+ IsFile bool
203+ IsSymlink bool
204+ IsHardlink bool
205+ Linkname string
206+}
207+
208+// memberIndex is an insertion-ordered map from normalized path to member —
209+// order matters because the Go port must report the same *first* violation
210+// the Python dict-iteration-order version would (Python 3.7+ dicts preserve
211+// insertion order).
212+type memberIndex struct {
213+ order []string
214+ byName map[string]*tarMember
215+}
216+
217+// displayKey mirrors Python's `key or '.'` idiom used when reporting the
218+// root member by name.
219+func displayKey(key string) string {
220+ if key == "" {
221+ return "."
222+ }
223+ return key
224+}
225+
226+// safeText mirrors safe_text(): rejects NUL and other C0/DEL control
227+// characters in a member name or link target.
228+func safeText(value, label string) error {
229+ for _, r := range value {
230+ if r < 32 || r == 127 {
231+ return fmt.Errorf("%s contains control characters", label)
232+ }
233+ }
234+ return nil
235+}
236+
237+// posixPartsRaw splits a POSIX path into its components the way
238+// PurePosixPath(...).parts does before any "." filtering: repeated/leading/
239+// trailing slashes collapse away, but "." segments are preserved literally
240+// (pathlib doesn't collapse "." either) so ".." detection above can see
241+// them in their original positions. It never returns the pseudo "/" root
242+// component pathlib.parts would include for an absolute path — callers that
243+// care about absoluteness check hdr.Name's leading "/" directly instead.
244+func posixPartsRaw(p string) []string {
245+ var out []string
246+ for _, seg := range strings.Split(p, "/") {
247+ if seg == "" {
248+ continue
249+ }
250+ out = append(out, seg)
251+ }
252+ return out
253+}
254+
255+// linkParts mirrors link_parts(): validates the raw link target text, then
256+// returns its normalized path components (dropping "" and "." segments;
257+// ".." is kept literally so the caller can walk it).
258+func linkParts(value string) ([]string, error) {
259+ if err := safeText(value, "archive link target"); err != nil {
260+ return nil, err
261+ }
262+ if strings.HasPrefix(value, "/") {
263+ return nil, errors.New("archive link target is absolute")
264+ }
265+ var out []string
266+ for _, seg := range posixPartsRaw(value) {
267+ if seg != "." {
268+ out = append(out, seg)
269+ }
270+ }
271+ return out, nil
272+}
273+
274+// validateLinkTarget mirrors validate_link_target(): simulates walking the
275+// link target's ".." segments against a stack seeded with the *link's own*
276+// containing directory (for symlinks only — hardlink targets in this format
277+// are always tar-root-relative, per the original), and rejects a target
278+// that would underflow past the extraction root. It only proves the target
279+// text is lexically well-formed; resolve() (below) does the real
280+// existence/type checks.
281+func validateLinkTarget(member *tarMember, key string) error {
282+ parts, err := linkParts(member.Linkname)
283+ if err != nil {
284+ return err
285+ }
286+ if len(parts) == 0 {
287+ return errors.New("archive link target is empty")
288+ }
289+
290+ var resolved []string
291+ if member.IsSymlink {
292+ resolved = keyDirParts(key)
293+ }
294+ for _, part := range parts {
295+ if part == ".." {
296+ if len(resolved) == 0 {
297+ return errors.New("archive link target lexically escapes extraction root")
298+ }
299+ resolved = resolved[:len(resolved)-1]
300+ } else {
301+ resolved = append(resolved, part)
302+ }
303+ }
304+ return nil
305+}
306+
307+// keyDirParts mirrors `key.split("/")[:-1]`: the path components of key's
308+// containing directory.
309+func keyDirParts(key string) []string {
310+ if key == "" {
311+ return nil
312+ }
313+ parts := strings.Split(key, "/")
314+ return parts[:len(parts)-1]
315+}
316+
317+// resolve mirrors the Python resolve() closure: fully walks key's
318+// symlink/hardlink chain (if any) to the real member it ultimately names,
319+// with the same cycle detection and safety-bound semantics as the
320+// original.
321+func (idx *memberIndex) resolve(key string) (*tarMember, error) {
322+ var pending []string
323+ if key != "" {
324+ pending = strings.Split(key, "/")
325+ }
326+ var resolved []string
327+ seenStates := map[string]bool{}
328+ steps := 0
329+ maxSteps := len(idx.order)*8 + 32
330+
331+ for len(pending) > 0 {
332+ steps++
333+ if steps > maxSteps {
334+ return nil, errors.New("archive link chain exceeds safety bound")
335+ }
336+ state := resolveStateKey(resolved, pending)
337+ if seenStates[state] {
338+ return nil, errors.New("archive link cycle detected")
339+ }
340+ seenStates[state] = true
341+
342+ part := pending[0]
343+ pending = pending[1:]
344+
345+ if part == "" || part == "." {
346+ continue
347+ }
348+ if part == ".." {
349+ if len(resolved) == 0 {
350+ return nil, errors.New("archive link target escapes extraction root")
351+ }
352+ resolved = resolved[:len(resolved)-1]
353+ continue
354+ }
355+
356+ // candidate == strings.Join(append(resolved, part), "/"); computed
357+ // without mutating resolved since it's only used for a lookup here.
358+ candidate := part
359+ if len(resolved) > 0 {
360+ candidate = strings.Join(resolved, "/") + "/" + part
361+ }
362+
363+ entry, ok := idx.byName[candidate]
364+ if ok && (entry.IsSymlink || entry.IsHardlink) {
365+ var base []string
366+ if entry.IsSymlink {
367+ base = append([]string{}, resolved...)
368+ }
369+ targetParts, err := linkParts(entry.Linkname)
370+ if err != nil {
371+ return nil, err
372+ }
373+ next := make([]string, 0, len(base)+len(targetParts)+len(pending))
374+ next = append(next, base...)
375+ next = append(next, targetParts...)
376+ next = append(next, pending...)
377+ pending = next
378+ resolved = nil
379+ continue
380+ }
381+
382+ if len(pending) > 0 && (!ok || !entry.IsDir) {
383+ return nil, fmt.Errorf("archive path resolves through missing or non-directory member: %s", candidate)
384+ }
385+ resolved = append(resolved, part)
386+ }
387+
388+ final := strings.Join(resolved, "/")
389+ entry, ok := idx.byName[final]
390+ if !ok {
391+ return nil, fmt.Errorf("archive link resolves to missing member: %s", displayKey(final))
392+ }
393+ return entry, nil
394+}
395+
396+// resolveStateKey mirrors `(tuple(resolved), tuple(pending))` as a hashable
397+// set element. NUL is safe as an internal separator: every path component
398+// reaching here already passed safeText, which rejects NUL and other
399+// control characters.
400+func resolveStateKey(resolved, pending []string) string {
401+ return strings.Join(resolved, "\x00") + "\x01" + strings.Join(pending, "\x00")
402+}
internal/archive/validator_test.go+340−0
@@ -0,0 +1,340 @@
1+package archive
2+
3+import (
4+ "archive/tar"
5+ "bytes"
6+ "compress/gzip"
7+ "fmt"
8+ "strings"
9+ "testing"
10+)
11+
12+// testEntry describes one tar member for building synthetic archives in
13+// tests, without going through the real docker/tar-inside-a-container path.
14+type testEntry struct {
15+ Name string
16+ Typeflag byte
17+ Linkname string
18+ Body string
19+}
20+
21+func buildTarGz(t *testing.T, entries []testEntry) []byte {
22+ t.Helper()
23+ var buf bytes.Buffer
24+ gz := gzip.NewWriter(&buf)
25+ tw := tar.NewWriter(gz)
26+ for _, e := range entries {
27+ mode := int64(0644)
28+ if e.Typeflag == tar.TypeDir {
29+ mode = 0755
30+ }
31+ hdr := &tar.Header{
32+ Name: e.Name,
33+ Typeflag: e.Typeflag,
34+ Linkname: e.Linkname,
35+ Size: int64(len(e.Body)),
36+ Mode: mode,
37+ }
38+ if err := tw.WriteHeader(hdr); err != nil {
39+ t.Fatalf("write header %q: %v", e.Name, err)
40+ }
41+ if e.Body != "" {
42+ if _, err := tw.Write([]byte(e.Body)); err != nil {
43+ t.Fatalf("write body %q: %v", e.Name, err)
44+ }
45+ }
46+ }
47+ if err := tw.Close(); err != nil {
48+ t.Fatalf("close tar: %v", err)
49+ }
50+ if err := gz.Close(); err != nil {
51+ t.Fatalf("close gzip: %v", err)
52+ }
53+ return buf.Bytes()
54+}
55+
56+func dir(name string) testEntry { return testEntry{Name: name, Typeflag: tar.TypeDir} }
57+
58+func file(name, body string) testEntry {
59+ return testEntry{Name: name, Typeflag: tar.TypeReg, Body: body}
60+}
61+
62+func symlink(name, target string) testEntry {
63+ return testEntry{Name: name, Typeflag: tar.TypeSymlink, Linkname: target}
64+}
65+
66+func hardlink(name, target string) testEntry {
67+ return testEntry{Name: name, Typeflag: tar.TypeLink, Linkname: target}
68+}
69+
70+// validBase is the minimal set of members every accepted archive needs:
71+// a real root directory plus the required home/home-agent tree (rule 9).
72+func validBase() []testEntry {
73+ return []testEntry{
74+ dir(""),
75+ dir("home"),
76+ dir("home/agent"),
77+ }
78+}
79+
80+func runValidate(t *testing.T, entries []testEntry) error {
81+ t.Helper()
82+ data := buildTarGz(t, entries)
83+ return ValidateDataTar(bytes.NewReader(data))
84+}
85+
86+func TestValidateDataTar_AcceptsValidArchive(t *testing.T) {
87+ entries := append(validBase(),
88+ file("home/agent/foo.txt", "hello"),
89+ symlink("home/agent/link", "foo.txt"),
90+ hardlink("home/agent/hardlink", "home/agent/foo.txt"),
91+ )
92+ if err := runValidate(t, entries); err != nil {
93+ t.Fatalf("expected valid archive to pass, got: %v", err)
94+ }
95+}
96+
97+func TestValidateDataTar_AcceptsMinimalArchive(t *testing.T) {
98+ if err := runValidate(t, validBase()); err != nil {
99+ t.Fatalf("expected minimal home/agent-only archive to pass, got: %v", err)
100+ }
101+}
102+
103+// TestValidateDataTar_Rejects is the comprehensive table covering every
104+// rule enumerated in dossier §6.3 (the Python `_validate_archive` this
105+// validator ports), plus the extra corrupt-stream case.
106+func TestValidateDataTar_Rejects(t *testing.T) {
107+ cases := []struct {
108+ name string
109+ entries []testEntry
110+ wantErr string
111+ }{
112+ {
113+ name: "empty archive",
114+ entries: nil,
115+ wantErr: "archive is empty",
116+ },
117+ {
118+ name: "absolute path",
119+ entries: append(validBase(), file("/etc/passwd", "x")),
120+ wantErr: "archive path escapes its extraction root",
121+ },
122+ {
123+ name: "traversal path",
124+ entries: append(validBase(), file("home/../../etc/passwd", "x")),
125+ wantErr: "archive path escapes its extraction root",
126+ },
127+ {
128+ name: "control char in name",
129+ entries: append(validBase(), file("home/agent/\x01bad", "x")),
130+ wantErr: "archive path contains control characters",
131+ },
132+ {
133+ name: "control char in link target",
134+ entries: append(validBase(), symlink("home/agent/link", "\x01bad")),
135+ wantErr: "archive link target contains control characters",
136+ },
137+ {
138+ name: "duplicate path",
139+ entries: []testEntry{
140+ dir(""),
141+ dir("home/"),
142+ dir("./home"),
143+ },
144+ wantErr: "archive contains duplicate path: home",
145+ },
146+ {
147+ name: "special file (fifo)",
148+ entries: append(validBase(), testEntry{Name: "home/agent/fifo", Typeflag: tar.TypeFifo}),
149+ wantErr: "archive contains a device, FIFO, socket, or unknown special file",
150+ },
151+ {
152+ name: "root member not a directory",
153+ entries: []testEntry{file(".", "x")},
154+ wantErr: "archive root member must be a real directory",
155+ },
156+ {
157+ name: "root member is a symlink",
158+ entries: []testEntry{symlink(".", "somewhere")},
159+ wantErr: "archive root member must be a real directory",
160+ },
161+ {
162+ name: "root member is a hardlink",
163+ entries: []testEntry{hardlink(".", "somewhere")},
164+ wantErr: "archive root member must be a real directory",
165+ },
166+ {
167+ name: "missing home",
168+ entries: []testEntry{dir("")},
169+ wantErr: "home must be a real directory",
170+ },
171+ {
172+ name: "home is a symlink",
173+ entries: []testEntry{
174+ dir(""),
175+ symlink("home", "somewhere"),
176+ },
177+ wantErr: "home must be a real directory",
178+ },
179+ {
180+ name: "missing home/agent",
181+ entries: []testEntry{
182+ dir(""),
183+ dir("home"),
184+ },
185+ wantErr: "home/agent must be a real directory",
186+ },
187+ {
188+ name: "link target is absolute",
189+ entries: append(validBase(), symlink("home/agent/link", "/etc/passwd")),
190+ wantErr: "archive link target is absolute",
191+ },
192+ {
193+ name: "link target is empty",
194+ entries: append(validBase(), symlink("home/agent/link", "")),
195+ wantErr: "archive link target is empty",
196+ },
197+ {
198+ name: "link target lexically escapes root",
199+ entries: append(validBase(), symlink("home/agent/link", "../../../etc/passwd")),
200+ wantErr: "archive link target lexically escapes extraction root",
201+ },
202+ {
203+ name: "nested beneath a symlink",
204+ entries: append(validBase(),
205+ symlink("home/agent/alias", "placeholder"),
206+ file("home/agent/alias/child", "x"),
207+ ),
208+ wantErr: "archive member is nested beneath a link: home/agent/alias/child",
209+ },
210+ {
211+ name: "nested beneath a hardlink",
212+ entries: append(validBase(),
213+ hardlink("home/agent/alias", "home/agent"),
214+ file("home/agent/alias/child", "x"),
215+ ),
216+ wantErr: "archive member is nested beneath a link: home/agent/alias/child",
217+ },
218+ {
219+ name: "link cycle",
220+ entries: []testEntry{
221+ dir(""),
222+ dir("home"),
223+ dir("home/agent"),
224+ symlink("a", "b"),
225+ symlink("b", "a"),
226+ },
227+ wantErr: "archive link cycle detected",
228+ },
229+ {
230+ name: "link chain exceeds safety bound",
231+ entries: []testEntry{
232+ dir(""),
233+ dir("home"),
234+ dir("home/agent"),
235+ symlink("a", "a/a"),
236+ },
237+ wantErr: "archive link chain exceeds safety bound",
238+ },
239+ {
240+ name: "link resolves to missing member",
241+ entries: append(validBase(), symlink("orphan", "ghost")),
242+ wantErr: "archive link resolves to missing member: ghost",
243+ },
244+ {
245+ name: "path resolves through missing member",
246+ entries: append(validBase(), file("home/agent/x/y/child", "x")),
247+ wantErr: "archive path resolves through missing or non-directory member: home/agent/x",
248+ },
249+ {
250+ name: "path resolves through non-directory member",
251+ entries: append(validBase(),
252+ file("home/agent/notadir", "x"),
253+ file("home/agent/notadir/deeper/child", "x"),
254+ ),
255+ wantErr: "archive path resolves through missing or non-directory member: home/agent/notadir",
256+ },
257+ {
258+ name: "member parent is not a directory",
259+ entries: append(validBase(),
260+ file("home/agent/notadir", "x"),
261+ file("home/agent/notadir/child", "x"),
262+ ),
263+ wantErr: "archive member parent is not a directory: home/agent/notadir/child",
264+ },
265+ {
266+ name: "hardlink does not resolve to a regular file",
267+ entries: append(validBase(), hardlink("home/agent/hardlink", "home/agent")),
268+ wantErr: "archive hardlink does not resolve to a regular file",
269+ },
270+ }
271+
272+ for _, tc := range cases {
273+ t.Run(tc.name, func(t *testing.T) {
274+ err := runValidate(t, tc.entries)
275+ if err == nil {
276+ t.Fatalf("expected error containing %q, got nil", tc.wantErr)
277+ }
278+ if !strings.Contains(err.Error(), tc.wantErr) {
279+ t.Fatalf("expected error containing %q, got: %v", tc.wantErr, err)
280+ }
281+ })
282+ }
283+}
284+
285+func TestValidateDataTar_NotGzip(t *testing.T) {
286+ if err := ValidateDataTar(strings.NewReader("not a gzip stream")); err == nil {
287+ t.Fatal("expected error for non-gzip input")
288+ }
289+}
290+
291+// TestValidateDataTar_CorruptGzipTrailer guards against a bug where
292+// tar.Next() returning io.EOF (its own end-of-archive marker) was treated
293+// as proof the whole stream was sound, even though the gzip trailer's
294+// CRC32/ISIZE footer had never actually been read/verified. Flipping the
295+// last 4 bytes of a valid archive corrupts that trailer without touching
296+// any tar content, so this only fails if the trailer is actually checked.
297+func TestValidateDataTar_CorruptGzipTrailer(t *testing.T) {
298+ data := buildTarGz(t, append(validBase(), file("home/agent/foo.txt", "hello")))
299+ corrupted := append([]byte(nil), data...)
300+ for i := len(corrupted) - 4; i < len(corrupted); i++ {
301+ corrupted[i] ^= 0xff
302+ }
303+ if err := ValidateDataTar(bytes.NewReader(corrupted)); err == nil {
304+ t.Fatal("expected error for archive with a corrupt gzip trailer")
305+ }
306+}
307+
308+// TestValidateDataTar_MemberCapExceeded exercises the maxArchiveMembers
309+// guard without needing to actually construct a million-entry archive: the
310+// var (not const) is lowered for the duration of the test.
311+func TestValidateDataTar_MemberCapExceeded(t *testing.T) {
312+ orig := maxArchiveMembers
313+ maxArchiveMembers = 10
314+ defer func() { maxArchiveMembers = orig }()
315+
316+ entries := validBase()
317+ for i := 0; i < maxArchiveMembers+5; i++ {
318+ entries = append(entries, file(fmt.Sprintf("home/agent/f%d", i), "x"))
319+ }
320+ err := runValidate(t, entries)
321+ if err == nil {
322+ t.Fatal("expected error for archive exceeding the member cap")
323+ }
324+ if !strings.Contains(err.Error(), "more than") {
325+ t.Fatalf("expected a member-cap error, got: %v", err)
326+ }
327+}
328+
329+func TestValidateDataTar_TruncatedGzip(t *testing.T) {
330+ // Cut the stream in half rather than trimming a few trailing bytes: the
331+ // gzip footer alone can be dropped without tripping an error, since the
332+ // tar reader may finish consuming all entries before the decompressor
333+ // ever needs to validate the trailing CRC/ISIZE. Truncating mid-stream
334+ // guarantees the tar/gzip readers hit a genuine unexpected EOF.
335+ data := buildTarGz(t, append(validBase(), file("home/agent/foo.txt", strings.Repeat("x", 4096))))
336+ truncated := data[:len(data)/2]
337+ if err := ValidateDataTar(bytes.NewReader(truncated)); err == nil {
338+ t.Fatal("expected error for truncated archive")
339+ }
340+}
internal/assets/assets.go+114−0
@@ -0,0 +1,114 @@
1+// Package assets turns the embedded build-context filesystem (see the
2+// root-level assets.go) into a tar stream suitable for the Docker Engine
3+// API's ImageBuild. It is parameterized over fs.FS rather than importing
4+// the embed var directly, since that var lives in package main (see
5+// assets.go for why) and Go forbids importing package main.
6+package assets
7+
8+import (
9+ "archive/tar"
10+ "bytes"
11+ "fmt"
12+ "io"
13+ "io/fs"
14+ "path"
15+ "strings"
16+)
17+
18+// DockerfilePath is where the embedded Dockerfile lands inside the build
19+// context tar; pass this as the ImageBuild Dockerfile option.
20+const DockerfilePath = "docker/Dockerfile"
21+
22+// BuildContextTar walks src and produces a tar stream matching the layout
23+// docker/Dockerfile's COPY instructions expect (build context = repo root:
24+// box.env, provision/, guest/, docker/entrypoint.sh, docker/executor-entrypoint.sh).
25+//
26+// go:embed strips the executable bit from every file, so shell scripts and
27+// other executables are explicitly re-marked 0755 here; everything else is
28+// 0644 (files) / 0755 (directories). Python bytecode caches (__pycache__,
29+// *.pyc) that may be present on disk under guest/ are skipped — they are
30+// build artifacts, not source.
31+func BuildContextTar(src fs.FS) (io.Reader, error) {
32+ var buf bytes.Buffer
33+ tw := tar.NewWriter(&buf)
34+
35+ err := fs.WalkDir(src, ".", func(p string, d fs.DirEntry, err error) error {
36+ if err != nil {
37+ return err
38+ }
39+ if p == "." {
40+ return nil
41+ }
42+ if skip(p) {
43+ if d.IsDir() {
44+ return fs.SkipDir
45+ }
46+ return nil
47+ }
48+
49+ if d.IsDir() {
50+ hdr := &tar.Header{
51+ Name: p + "/",
52+ Typeflag: tar.TypeDir,
53+ Mode: 0o755,
54+ }
55+ return tw.WriteHeader(hdr)
56+ }
57+
58+ data, err := fs.ReadFile(src, p)
59+ if err != nil {
60+ return fmt.Errorf("assets: read %s: %w", p, err)
61+ }
62+ hdr := &tar.Header{
63+ Name: p,
64+ Typeflag: tar.TypeReg,
65+ Mode: fileMode(p),
66+ Size: int64(len(data)),
67+ }
68+ if err := tw.WriteHeader(hdr); err != nil {
69+ return fmt.Errorf("assets: write header %s: %w", p, err)
70+ }
71+ if _, err := tw.Write(data); err != nil {
72+ return fmt.Errorf("assets: write %s: %w", p, err)
73+ }
74+ return nil
75+ })
76+ if err != nil {
77+ return nil, err
78+ }
79+ if err := tw.Close(); err != nil {
80+ return nil, fmt.Errorf("assets: close tar: %w", err)
81+ }
82+ return &buf, nil
83+}
84+
85+// skip reports whether p is a build artifact that should not ship in the
86+// build context (Python bytecode caches under guest/).
87+func skip(p string) bool {
88+ if strings.Contains(p, "__pycache__") {
89+ return true
90+ }
91+ if path.Ext(p) == ".pyc" {
92+ return true
93+ }
94+ return false
95+}
96+
97+// fileMode returns the tar mode for path p, restoring the executable bit
98+// go:embed strips: any *.sh script, guest/hb and guest/hb-workload (the
99+// guest control binaries), guest/hermes-state, and everything under
100+// provision/ need 0755. Everything else is a plain 0644 data file.
101+func fileMode(p string) int64 {
102+ switch {
103+ case strings.HasSuffix(p, ".sh"):
104+ return 0o755
105+ case p == "guest/hb" || strings.HasPrefix(p, "guest/hb-"):
106+ return 0o755
107+ case p == "guest/hermes-state":
108+ return 0o755
109+ case strings.HasPrefix(p, "provision/"):
110+ return 0o755
111+ default:
112+ return 0o644
113+ }
114+}
internal/assets/assets_test.go+64−0
@@ -0,0 +1,64 @@
1+package assets
2+
3+import (
4+ "archive/tar"
5+ "io"
6+ "io/fs"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+// repoRootFS builds an fs.FS over the same subset of the repo the real
13+// go:embed directive in the root assets.go covers, without needing to
14+// import package main (which is impossible — see assets.go's doc comment).
15+func repoRootFS(t *testing.T) fs.FS {
16+ t.Helper()
17+ wd, err := os.Getwd()
18+ if err != nil {
19+ t.Fatalf("Getwd: %v", err)
20+ }
21+ root := filepath.Join(wd, "..", "..")
22+ return os.DirFS(root)
23+}
24+
25+func TestBuildContextTar(t *testing.T) {
26+ r, err := BuildContextTar(repoRootFS(t))
27+ if err != nil {
28+ t.Fatalf("BuildContextTar: %v", err)
29+ }
30+
31+ modes := map[string]int64{}
32+ tr := tar.NewReader(r)
33+ for {
34+ hdr, err := tr.Next()
35+ if err == io.EOF {
36+ break
37+ }
38+ if err != nil {
39+ t.Fatalf("tar.Next: %v", err)
40+ }
41+ modes[hdr.Name] = hdr.Mode
42+ }
43+
44+ if _, ok := modes["docker/Dockerfile"]; !ok {
45+ t.Errorf("docker/Dockerfile missing from build context tar")
46+ }
47+ mode, ok := modes["guest/hb"]
48+ if !ok {
49+ t.Fatalf("guest/hb missing from build context tar")
50+ }
51+ if mode != 0o755 {
52+ t.Errorf("guest/hb mode = %o, want 0755", mode)
53+ }
54+ for _, want := range []string{"box.env", "provision/provision.sh", "docker/entrypoint.sh", "docker/executor-entrypoint.sh", "guest/hb-workload", "guest/hermes-state"} {
55+ if _, ok := modes[want]; !ok {
56+ t.Errorf("%s missing from build context tar", want)
57+ }
58+ }
59+ for name := range modes {
60+ if name == "guest/__pycache__/" || name == "guest/__pycache__" {
61+ t.Errorf("build context tar should not contain __pycache__, found %s", name)
62+ }
63+ }
64+}
internal/box/box.go+272−0
@@ -0,0 +1,272 @@
1+// Package box implements the tx9 box lifecycle: deriving names for a box's
2+// Docker objects, reconstructing box state from daemon labels, and driving
3+// the container topology (dossier §1) through internal/docker's Engine API
4+// wrapper. It is the shared contract between the lifecycle commands
5+// (create/list/enter/start/stop/open/doctor/delete/prune/upgrade) and the
6+// backup/import commands, which only ever go through this package's
7+// exported API, never internal/docker directly.
8+package box
9+
10+import (
11+ "context"
12+ "errors"
13+ "fmt"
14+ "os"
15+ "sort"
16+
17+ dockernetwork "github.com/docker/docker/api/types/network"
18+ dockerclient "github.com/docker/docker/client"
19+ "github.com/docker/go-connections/nat"
20+
21+ "github.com/davis7dotsh/tx9/internal/docker"
22+ "github.com/davis7dotsh/tx9/internal/state"
23+)
24+
25+// ErrNotFound is wrapped into the error Get/Exists return when no
26+// tx9-labeled container exists for a given box name.
27+var ErrNotFound = errors.New("box not found")
28+
29+// executorPort is the container-side port the executor daemon always binds
30+// (dossier §1.1/§1.4 — fixed, never configurable).
31+const executorPort = "4788/tcp"
32+
33+// Box is a snapshot of one box's state, reconstructed from the daemon's
34+// tx9-labeled containers (decision 2, tx9-cli-design.md: ~/.tx9 is a cache,
35+// the daemon's labels are the source of truth).
36+type Box struct {
37+ Name string
38+ AgentID string // container ID, "" if missing
39+ ExecutorID string
40+ AgentState string // docker state string: running/exited/...
41+ ExecutorState string
42+ Version string // tx9.version label value
43+}
44+
45+// DerivedState collapses AgentState/ExecutorState into one of
46+// running/stopped/mixed/crashed for display (tx9 list).
47+func (b Box) DerivedState() string {
48+ if b.AgentID == "" || b.ExecutorID == "" {
49+ return "crashed"
50+ }
51+ agentUp := b.AgentState == "running"
52+ execUp := b.ExecutorState == "running"
53+ switch {
54+ case agentUp && execUp:
55+ return "running"
56+ case !agentUp && !execUp:
57+ return "stopped"
58+ default:
59+ return "mixed"
60+ }
61+}
62+
63+// ContainerNames returns the two container names tx9 mints for box name:
64+// "tx9-<name>-agent" and "tx9-<name>-executor".
65+func ContainerNames(name string) (agent, executor string) {
66+ return "tx9-" + name + "-agent", "tx9-" + name + "-executor"
67+}
68+
69+// NetworkName returns the per-box private bridge network name: "tx9-<name>".
70+func NetworkName(name string) string {
71+ return "tx9-" + name
72+}
73+
74+// VolumeNames returns the two named volumes tx9 mints for box name:
75+// "tx9-<name>-agent-data" (the box's durable identity) and
76+// "tx9-<name>-exec-data" (scratch, dossier §12 — never backed up).
77+func VolumeNames(name string) (agentData, execData string) {
78+ return "tx9-" + name + "-agent-data", "tx9-" + name + "-exec-data"
79+}
80+
81+// List returns every box this daemon knows about, reconstructed purely from
82+// tx9=1-labeled containers (order: sorted by name).
83+func List(ctx context.Context, cli *docker.Client) ([]Box, error) {
84+ containers, err := cli.ListBoxContainers(ctx)
85+ if err != nil {
86+ return nil, fmt.Errorf("box: list: %w", err)
87+ }
88+
89+ byName := map[string]*Box{}
90+ var order []string
91+ for _, c := range containers {
92+ name := c.Labels[docker.LabelBox]
93+ if name == "" {
94+ continue
95+ }
96+ b, ok := byName[name]
97+ if !ok {
98+ b = &Box{Name: name}
99+ byName[name] = b
100+ order = append(order, name)
101+ }
102+ if v := c.Labels[docker.LabelVersion]; v != "" {
103+ b.Version = v
104+ }
105+ switch c.Labels[docker.LabelRole] {
106+ case docker.RoleAgent:
107+ b.AgentID = c.ID
108+ b.AgentState = c.State
109+ case docker.RoleExecutor:
110+ b.ExecutorID = c.ID
111+ b.ExecutorState = c.State
112+ }
113+ }
114+
115+ sort.Strings(order)
116+ boxes := make([]Box, 0, len(order))
117+ for _, name := range order {
118+ boxes = append(boxes, *byName[name])
119+ }
120+ return boxes, nil
121+}
122+
123+// Get returns the box named name, or an error wrapping ErrNotFound if no
124+// tx9-labeled container exists for it.
125+func Get(ctx context.Context, cli *docker.Client, name string) (*Box, error) {
126+ boxes, err := List(ctx, cli)
127+ if err != nil {
128+ return nil, err
129+ }
130+ for i := range boxes {
131+ if boxes[i].Name == name {
132+ return &boxes[i], nil
133+ }
134+ }
135+ return nil, fmt.Errorf("box %q: %w", name, ErrNotFound)
136+}
137+
138+// Exists reports whether a box named name has any tx9-labeled container on
139+// this daemon.
140+func Exists(ctx context.Context, cli *docker.Client, name string) (bool, error) {
141+ _, err := Get(ctx, cli, name)
142+ if err == nil {
143+ return true, nil
144+ }
145+ if errors.Is(err, ErrNotFound) {
146+ return false, nil
147+ }
148+ return false, err
149+}
150+
151+// Token returns the box's executor bearer token from ~/.tx9/boxes/<name>.env
152+// (dossier §2.2 — the host-side cache; the daemon has no label for secrets).
153+func Token(name string) (string, error) {
154+ env, err := state.ReadBoxEnv(name)
155+ if err != nil {
156+ return "", fmt.Errorf("box: token %s: %w", name, err)
157+ }
158+ tok := env["EXECUTOR_MCP_TOKEN"]
159+ if tok == "" {
160+ return "", fmt.Errorf("box: token %s: EXECUTOR_MCP_TOKEN not found in ~/.tx9/boxes/%s.env", name, name)
161+ }
162+ return tok, nil
163+}
164+
165+// Start starts both containers, executor first so the agent's dependent
166+// services find it already listening (mirrors compose's depends_on, dossier
167+// §1.1).
168+func Start(ctx context.Context, cli *docker.Client, b *Box) error {
169+ if b.AgentID == "" || b.ExecutorID == "" {
170+ return fmt.Errorf("box: start %s: missing container(s) (agent=%q executor=%q)", b.Name, b.AgentID, b.ExecutorID)
171+ }
172+ if err := cli.ContainerStart(ctx, b.ExecutorID); err != nil {
173+ return fmt.Errorf("box: start %s: executor: %w", b.Name, err)
174+ }
175+ if err := cli.ContainerStart(ctx, b.AgentID); err != nil {
176+ return fmt.Errorf("box: start %s: agent: %w", b.Name, err)
177+ }
178+ return nil
179+}
180+
181+// Stop stops both containers, agent first (the reverse of Start) so it gets
182+// a clean chance to shut down before its executor dependency disappears.
183+func Stop(ctx context.Context, cli *docker.Client, b *Box) error {
184+ var errs []error
185+ if b.AgentID != "" {
186+ if err := cli.ContainerStop(ctx, b.AgentID, nil); err != nil {
187+ errs = append(errs, fmt.Errorf("agent: %w", err))
188+ }
189+ }
190+ if b.ExecutorID != "" {
191+ if err := cli.ContainerStop(ctx, b.ExecutorID, nil); err != nil {
192+ errs = append(errs, fmt.Errorf("executor: %w", err))
193+ }
194+ }
195+ if len(errs) > 0 {
196+ return fmt.Errorf("box: stop %s: %w", b.Name, errors.Join(errs...))
197+ }
198+ return nil
199+}
200+
201+// HostPort returns the published host port of the executor's 4788/tcp
202+// (dossier §1.4 — Docker auto-assigns this at container start since the
203+// spec pins no host port).
204+func HostPort(ctx context.Context, cli *docker.Client, b *Box) (string, error) {
205+ if b.ExecutorID == "" {
206+ return "", fmt.Errorf("box: host port %s: executor container missing", b.Name)
207+ }
208+ inspect, err := cli.ContainerInspect(ctx, b.ExecutorID)
209+ if err != nil {
210+ return "", fmt.Errorf("box: host port %s: %w", b.Name, err)
211+ }
212+ bindings, ok := inspect.NetworkSettings.Ports[nat.Port(executorPort)]
213+ if !ok || len(bindings) == 0 {
214+ return "", fmt.Errorf("box: host port %s: executor port %s not published (is the box running?)", b.Name, executorPort)
215+ }
216+ return bindings[0].HostPort, nil
217+}
218+
219+// URLHost returns the hostname tx9 prints in dashboard/open URLs:
220+// TX9_URL_HOST env override, falling back to the machine's own hostname
221+// (dossier §3/§4 used a hardcoded siva.davis7.space default; tx9 makes this
222+// configurable per-install instead).
223+func URLHost() string {
224+ if h := os.Getenv("TX9_URL_HOST"); h != "" {
225+ return h
226+ }
227+ if h, err := os.Hostname(); err == nil && h != "" {
228+ return h
229+ }
230+ return "localhost"
231+}
232+
233+// DashboardURL is the box's unauthenticated dashboard URL (no token) —
234+// what `tx9 create` prints (dossier §3 step 9): meant to be shared/logged
235+// freely.
236+func DashboardURL(port string) string {
237+ return fmt.Sprintf("http://%s:%s/", URLHost(), port)
238+}
239+
240+// OpenURL is the box's authenticated dashboard URL, with the bearer token
241+// embedded in the query string (dossier §4) — what `tx9 open` prints: a
242+// one-shot secret, not meant for logs.
243+func OpenURL(port, token string) string {
244+ return fmt.Sprintf("http://%s:%s/?_token=%s", URLHost(), port, token)
245+}
246+
247+// ensureNetwork returns the per-box network's ID, creating it if absent so
248+// Create is idempotent for this object (dossier's upgrade path recreates
249+// containers but keeps the network).
250+func ensureNetwork(ctx context.Context, cli *docker.Client, netName, boxName, ver string) (string, error) {
251+ if insp, err := cli.Raw().NetworkInspect(ctx, netName, dockernetwork.InspectOptions{}); err == nil {
252+ return insp.ID, nil
253+ } else if !dockerclient.IsErrNotFound(err) {
254+ return "", fmt.Errorf("network inspect %s: %w", netName, err)
255+ }
256+ id, err := cli.NetworkCreate(ctx, netName, docker.BoxLabels(boxName, ver, ""))
257+ if err != nil {
258+ return "", err
259+ }
260+ return id, nil
261+}
262+
263+// ensureVolume returns volName, creating it if absent so Create is
264+// idempotent for volumes (same rationale as ensureNetwork).
265+func ensureVolume(ctx context.Context, cli *docker.Client, volName, boxName, ver string) (string, error) {
266+ if _, err := cli.Raw().VolumeInspect(ctx, volName); err == nil {
267+ return volName, nil
268+ } else if !dockerclient.IsErrNotFound(err) {
269+ return "", fmt.Errorf("volume inspect %s: %w", volName, err)
270+ }
271+ return cli.VolumeCreate(ctx, volName, docker.BoxLabels(boxName, ver, ""))
272+}
internal/box/box_test.go+107−0
@@ -0,0 +1,107 @@
1+package box
2+
3+import "testing"
4+
5+func TestContainerNames(t *testing.T) {
6+ agent, executor := ContainerNames("dry1")
7+ if agent != "tx9-dry1-agent" {
8+ t.Errorf("agent name = %q, want tx9-dry1-agent", agent)
9+ }
10+ if executor != "tx9-dry1-executor" {
11+ t.Errorf("executor name = %q, want tx9-dry1-executor", executor)
12+ }
13+}
14+
15+func TestNetworkName(t *testing.T) {
16+ if got := NetworkName("dry1"); got != "tx9-dry1" {
17+ t.Errorf("NetworkName = %q, want tx9-dry1", got)
18+ }
19+}
20+
21+func TestVolumeNames(t *testing.T) {
22+ agentVol, execVol := VolumeNames("dry1")
23+ if agentVol != "tx9-dry1-agent-data" {
24+ t.Errorf("agent volume = %q, want tx9-dry1-agent-data", agentVol)
25+ }
26+ if execVol != "tx9-dry1-exec-data" {
27+ t.Errorf("executor volume = %q, want tx9-dry1-exec-data", execVol)
28+ }
29+}
30+
31+func TestDerivedState(t *testing.T) {
32+ cases := []struct {
33+ name string
34+ b Box
35+ want string
36+ }{
37+ {
38+ name: "both running",
39+ b: Box{AgentID: "a", ExecutorID: "e", AgentState: "running", ExecutorState: "running"},
40+ want: "running",
41+ },
42+ {
43+ name: "both stopped",
44+ b: Box{AgentID: "a", ExecutorID: "e", AgentState: "exited", ExecutorState: "exited"},
45+ want: "stopped",
46+ },
47+ {
48+ name: "mixed: agent up, executor down",
49+ b: Box{AgentID: "a", ExecutorID: "e", AgentState: "running", ExecutorState: "exited"},
50+ want: "mixed",
51+ },
52+ {
53+ name: "mixed: executor up, agent down",
54+ b: Box{AgentID: "a", ExecutorID: "e", AgentState: "exited", ExecutorState: "running"},
55+ want: "mixed",
56+ },
57+ {
58+ name: "crashed: agent container missing entirely",
59+ b: Box{AgentID: "", ExecutorID: "e", AgentState: "", ExecutorState: "running"},
60+ want: "crashed",
61+ },
62+ {
63+ name: "crashed: executor container missing entirely",
64+ b: Box{AgentID: "a", ExecutorID: "", AgentState: "running", ExecutorState: ""},
65+ want: "crashed",
66+ },
67+ }
68+
69+ for _, tc := range cases {
70+ t.Run(tc.name, func(t *testing.T) {
71+ if got := tc.b.DerivedState(); got != tc.want {
72+ t.Errorf("DerivedState() = %q, want %q", got, tc.want)
73+ }
74+ })
75+ }
76+}
77+
78+func TestURLHostEnvOverride(t *testing.T) {
79+ t.Setenv("TX9_URL_HOST", "box.example.com")
80+ if got := URLHost(); got != "box.example.com" {
81+ t.Errorf("URLHost() = %q, want box.example.com", got)
82+ }
83+}
84+
85+func TestURLHostFallsBackToHostname(t *testing.T) {
86+ t.Setenv("TX9_URL_HOST", "")
87+ got := URLHost()
88+ if got == "" {
89+ t.Error("URLHost() = \"\", want a non-empty hostname or \"localhost\" fallback")
90+ }
91+}
92+
93+func TestDashboardURL(t *testing.T) {
94+ t.Setenv("TX9_URL_HOST", "box.example.com")
95+ if got := DashboardURL("32768"); got != "http://box.example.com:32768/" {
96+ t.Errorf("DashboardURL() = %q, want http://box.example.com:32768/", got)
97+ }
98+}
99+
100+func TestOpenURL(t *testing.T) {
101+ t.Setenv("TX9_URL_HOST", "box.example.com")
102+ got := OpenURL("32768", "sekret")
103+ want := "http://box.example.com:32768/?_token=sekret"
104+ if got != want {
105+ t.Errorf("OpenURL() = %q, want %q", got, want)
106+ }
107+}
internal/box/create.go+270−0
@@ -0,0 +1,270 @@
1+package box
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "strings"
9+ "time"
10+
11+ dockerclient "github.com/docker/docker/client"
12+ "github.com/docker/go-connections/nat"
13+
14+ "github.com/davis7dotsh/tx9/internal/docker"
15+ "github.com/davis7dotsh/tx9/internal/state"
16+ "github.com/davis7dotsh/tx9/internal/version"
17+)
18+
19+// ipv6DisableSysctl is applied to both containers (dossier §10 gotcha #1):
20+// the executor daemon binds "localhost", which resolves to ::1 first in a
21+// default Docker container, so health checks against 127.0.0.1 would see a
22+// closed port unless IPv6 is disabled in the container's netns.
23+var ipv6DisableSysctl = map[string]string{"net.ipv6.conf.all.disable_ipv6": "1"}
24+
25+// CPU/memory limits, ported verbatim from docker/compose.yaml's defaults
26+// (dossier §1.3/§1.4 live-inspect-confirmed): agent 4 CPUs / 8192 MiB,
27+// executor 2 CPUs / 2048 MiB.
28+const (
29+ agentNanoCPUs = 4_000_000_000
30+ agentMemoryBytes = 8192 * 1024 * 1024
31+ executorNanoCPUs = 2_000_000_000
32+ executorMemoryBytes = 2048 * 1024 * 1024
33+)
34+
35+// CreateOpts parameterizes Create.
36+type CreateOpts struct {
37+ Name string
38+ Image string // e.g. "tx9-box:dev"
39+ Token string
40+ NoStart bool // create network/volumes/containers but do not start them (import path)
41+}
42+
43+// Create materializes a box's network, volumes, and both containers per the
44+// dossier §1 topology, using tx9-* naming instead of boxd's hbl-*. Network
45+// and volume creation are idempotent (ensureNetwork/ensureVolume), so
46+// upgrade can call Create again against a box whose network/volumes already
47+// exist without erroring — only the containers (removed by the caller
48+// first) are created fresh each time.
49+func Create(ctx context.Context, cli *docker.Client, opts CreateOpts) (*Box, error) {
50+ name := opts.Name
51+ agentName, executorName := ContainerNames(name)
52+ netName := NetworkName(name)
53+ agentVol, execVol := VolumeNames(name)
54+ ver := version.Version
55+
56+ netID, err := ensureNetwork(ctx, cli, netName, name, ver)
57+ if err != nil {
58+ return nil, fmt.Errorf("box: create %s: %w", name, err)
59+ }
60+ if _, err := ensureVolume(ctx, cli, agentVol, name, ver); err != nil {
61+ return nil, fmt.Errorf("box: create %s: %w", name, err)
62+ }
63+ if _, err := ensureVolume(ctx, cli, execVol, name, ver); err != nil {
64+ return nil, fmt.Errorf("box: create %s: %w", name, err)
65+ }
66+
67+ agentSpec := docker.ContainerSpec{
68+ Name: agentName,
69+ Image: opts.Image,
70+ Entrypoint: []string{"/opt/hermes-box/bin/entrypoint"},
71+ Env: []string{
72+ "EXECUTOR_HOST=executor",
73+ "BOXD_EXECUTOR_TOKEN=" + opts.Token,
74+ "BOX_EXECUTOR_BRIDGE_PORT=4788",
75+ "BOX_HERMES_BRIDGE_PORT=0",
76+ },
77+ Labels: docker.BoxLabels(name, ver, docker.RoleAgent),
78+ NetworkID: netID,
79+ NetworkAliases: []string{"agent"},
80+ Binds: []string{agentVol + ":/data"},
81+ Sysctls: ipv6DisableSysctl,
82+ NanoCPUs: agentNanoCPUs,
83+ MemoryBytes: agentMemoryBytes,
84+ }
85+ agentID, err := cli.ContainerCreate(ctx, agentSpec)
86+ if err != nil {
87+ return nil, fmt.Errorf("box: create %s: agent: %w", name, err)
88+ }
89+
90+ execPort := nat.Port(executorPort)
91+ executorSpec := docker.ContainerSpec{
92+ Name: executorName,
93+ Image: opts.Image,
94+ Entrypoint: []string{"/opt/hermes-box/bin/executor-entrypoint"},
95+ Env: []string{"EXECUTOR_MCP_TOKEN=" + opts.Token},
96+ Labels: docker.BoxLabels(name, ver, docker.RoleExecutor),
97+ NetworkID: netID,
98+ NetworkAliases: []string{"executor"},
99+ Binds: []string{execVol + ":/data"},
100+ ExposedPorts: nat.PortSet{execPort: struct{}{}},
101+ PortBindings: nat.PortMap{execPort: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}},
102+ Sysctls: ipv6DisableSysctl,
103+ NanoCPUs: executorNanoCPUs,
104+ MemoryBytes: executorMemoryBytes,
105+ }
106+ executorID, err := cli.ContainerCreate(ctx, executorSpec)
107+ if err != nil {
108+ // The agent container was already created above; leaving it
109+ // behind on this failure path leaks it, which matters most on
110+ // the upgrade path where the caller doesn't call Destroy on
111+ // error. Best-effort force-remove it (ignoring not-found) before
112+ // returning.
113+ if rmErr := cli.ContainerRemove(ctx, agentID, true); rmErr != nil && !dockerclient.IsErrNotFound(rmErr) {
114+ return nil, fmt.Errorf("box: create %s: executor: %w (also failed to remove leaked agent container: %v)", name, err, rmErr)
115+ }
116+ return nil, fmt.Errorf("box: create %s: executor: %w", name, err)
117+ }
118+
119+ b := &Box{
120+ Name: name,
121+ AgentID: agentID,
122+ ExecutorID: executorID,
123+ AgentState: "created",
124+ ExecutorState: "created",
125+ Version: ver,
126+ }
127+
128+ if !opts.NoStart {
129+ if err := Start(ctx, cli, b); err != nil {
130+ return b, fmt.Errorf("box: create %s: %w", name, err)
131+ }
132+ b.AgentState = "running"
133+ b.ExecutorState = "running"
134+ }
135+ return b, nil
136+}
137+
138+// HB runs `hb <args...>` inside the agent container, exactly replicating
139+// boxd's `_guest_hb` remote-control pattern (dossier §3): `runuser` drops to
140+// the agent user, and BOXD_EXECUTOR_TOKEN takes precedence over whatever
141+// stale token might be baked into the box's restored on-disk
142+// executor-mcp.env (dossier §2.3/§7.4 — the real bug this env override
143+// exists to fix).
144+func HB(ctx context.Context, cli *docker.Client, b *Box, token string, stdout, stderr io.Writer, args ...string) error {
145+ if b.AgentID == "" {
146+ return fmt.Errorf("box: hb %s %v: agent container missing", b.Name, args)
147+ }
148+ cmd := append([]string{
149+ "runuser", "-u", "agent", "--",
150+ "env", "HOME=/data/home/agent",
151+ "EXECUTOR_HOST=executor",
152+ "BOXD_EXECUTOR_TOKEN=" + token,
153+ "bash", "--noprofile", "--norc", "-c",
154+ `. /etc/profile.d/hermes-box.sh; exec hb "$@"`,
155+ "hb",
156+ }, args...)
157+
158+ res, err := cli.Exec(ctx, b.AgentID, cmd, nil, "")
159+ if err != nil {
160+ return fmt.Errorf("box: hb %s %v: %w", b.Name, args, err)
161+ }
162+ if stdout != nil && res.Stdout != "" {
163+ io.WriteString(stdout, res.Stdout)
164+ }
165+ if stderr != nil && res.Stderr != "" {
166+ io.WriteString(stderr, res.Stderr)
167+ }
168+ if res.ExitCode != 0 {
169+ return fmt.Errorf("box: hb %s %v: exit %d: %s", b.Name, args, res.ExitCode, strings.TrimSpace(res.Stderr))
170+ }
171+ return nil
172+}
173+
174+// PrepareRuntime is the post-create/post-upgrade readiness gate (dossier §3
175+// step 8 / _prepare_runtime): `hb up` gets up to 6 attempts with a 15s sleep
176+// between them, because a freshly-started container needs real wall-clock
177+// time before its daemon lock holders (hb-workload's reconcile loop) settle
178+// (dossier §10 gotcha #2). Only once `hb up` succeeds does it run
179+// `hb wire-once` then `hb doctor`.
180+func PrepareRuntime(ctx context.Context, cli *docker.Client, b *Box, token string, out io.Writer) error {
181+ const attempts = 6
182+ const retryWait = 15 * time.Second
183+
184+ var upErr error
185+ for attempt := 1; attempt <= attempts; attempt++ {
186+ upErr = HB(ctx, cli, b, token, out, out, "up")
187+ if upErr == nil {
188+ break
189+ }
190+ fmt.Fprintf(out, "box %s: hb up attempt %d/%d failed: %v\n", b.Name, attempt, attempts, upErr)
191+ if attempt < attempts {
192+ select {
193+ case <-time.After(retryWait):
194+ case <-ctx.Done():
195+ return fmt.Errorf("box: prepare runtime %s: %w", b.Name, ctx.Err())
196+ }
197+ }
198+ }
199+ if upErr != nil {
200+ return fmt.Errorf("box: prepare runtime %s: hb up did not succeed after %d attempts: %w", b.Name, attempts, upErr)
201+ }
202+
203+ if err := HB(ctx, cli, b, token, out, out, "wire-once"); err != nil {
204+ return fmt.Errorf("box: prepare runtime %s: wire-once: %w", b.Name, err)
205+ }
206+ if err := HB(ctx, cli, b, token, out, out, "doctor"); err != nil {
207+ return fmt.Errorf("box: prepare runtime %s: doctor: %w", b.Name, err)
208+ }
209+ return nil
210+}
211+
212+// Destroy removes a box's containers (force), network, volumes, and cached
213+// token file. It tolerates partial existence (e.g. a box that failed
214+// mid-create) — every step is best-effort and errors are collected rather
215+// than short-circuiting, so a half-created box never gets "stuck"
216+// undeletable.
217+func Destroy(ctx context.Context, cli *docker.Client, name string) error {
218+ agentName, executorName := ContainerNames(name)
219+ netName := NetworkName(name)
220+ agentVol, execVol := VolumeNames(name)
221+
222+ var errs []error
223+
224+ b, err := Get(ctx, cli, name)
225+ switch {
226+ case err == nil:
227+ if b.AgentID != "" {
228+ if rmErr := cli.ContainerRemove(ctx, b.AgentID, true); rmErr != nil && !dockerclient.IsErrNotFound(rmErr) {
229+ errs = append(errs, rmErr)
230+ }
231+ }
232+ if b.ExecutorID != "" {
233+ if rmErr := cli.ContainerRemove(ctx, b.ExecutorID, true); rmErr != nil && !dockerclient.IsErrNotFound(rmErr) {
234+ errs = append(errs, rmErr)
235+ }
236+ }
237+ case errors.Is(err, ErrNotFound):
238+ // No labeled containers found via List (e.g. labels never got
239+ // applied because create failed very early) — fall back to
240+ // removing by the names we would have minted, best-effort.
241+ for _, cn := range []string{agentName, executorName} {
242+ if rmErr := cli.ContainerRemove(ctx, cn, true); rmErr != nil && !dockerclient.IsErrNotFound(rmErr) {
243+ errs = append(errs, rmErr)
244+ }
245+ }
246+ default:
247+ errs = append(errs, err)
248+ }
249+
250+ if rmErr := cli.NetworkRemove(ctx, netName); rmErr != nil && !dockerclient.IsErrNotFound(rmErr) {
251+ errs = append(errs, rmErr)
252+ }
253+ for _, vn := range []string{agentVol, execVol} {
254+ if rmErr := cli.VolumeRemove(ctx, vn, true); rmErr != nil && !dockerclient.IsErrNotFound(rmErr) {
255+ errs = append(errs, rmErr)
256+ }
257+ }
258+ if len(errs) > 0 {
259+ return fmt.Errorf("box: destroy %s: %w", name, errors.Join(errs...))
260+ }
261+
262+ // Only drop the token env file once every other removal above
263+ // succeeded — removing it unconditionally would strand a box that
264+ // still exists (e.g. because a volume remove failed) without the
265+ // token it needs.
266+ if rmErr := state.RemoveBoxEnv(name); rmErr != nil {
267+ return fmt.Errorf("box: destroy %s: %w", name, rmErr)
268+ }
269+ return nil
270+}
internal/cli/box_common.go+109−0
@@ -0,0 +1,109 @@
1+package cli
2+
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "os"
8+ "strings"
9+
10+ "github.com/davis7dotsh/tx9/internal/assets"
11+ "github.com/davis7dotsh/tx9/internal/docker"
12+ "github.com/davis7dotsh/tx9/internal/lock"
13+ "github.com/davis7dotsh/tx9/internal/state"
14+)
15+
16+// parseFlagsAnywhere parses fs accepting flags before or after positional
17+// arguments (stdlib flag stops at the first positional, which silently
18+// ignores trailing flags like `tx9 delete mybox --force`). Everything after
19+// a literal "--" stays positional.
20+func parseFlagsAnywhere(fs *flag.FlagSet, args []string) error {
21+ var flags, positionals []string
22+ rest := false
23+ for i := 0; i < len(args); i++ {
24+ a := args[i]
25+ switch {
26+ case rest || a == "-" || len(a) == 0 || a[0] != '-':
27+ positionals = append(positionals, a)
28+ case a == "--":
29+ rest = true
30+ default:
31+ flags = append(flags, a)
32+ // a bool flag never consumes the next arg; others do unless
33+ // written as -name=value
34+ name := strings.TrimLeft(a, "-")
35+ if eq := strings.IndexByte(name, '='); eq >= 0 {
36+ name = name[:eq]
37+ } else if f := fs.Lookup(name); f != nil {
38+ if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); !ok || !bf.IsBoolFlag() {
39+ if i+1 < len(args) {
40+ i++
41+ flags = append(flags, args[i])
42+ }
43+ }
44+ }
45+ }
46+ }
47+ return fs.Parse(append(flags, positionals...))
48+}
49+
50+// requireBoxName returns fs's sole positional argument as a box name, or an
51+// actionable usage error if it's missing or if there's more than one (e.g.
52+// `tx9 delete mybox typo --force` used to silently delete mybox and ignore
53+// the extra "typo" positional).
54+func requireBoxName(fs *flag.FlagSet, cmd string) (string, error) {
55+ if fs.NArg() != 1 {
56+ return "", fmt.Errorf("expected exactly one box name (usage: tx9 %s <box>)", cmd)
57+ }
58+ return fs.Arg(0), nil
59+}
60+
61+// withDocker opens a Docker Engine API client from the environment, runs
62+// fn, and always closes the client afterward.
63+func withDocker(fn func(ctx context.Context, cli *docker.Client) error) error {
64+ ctx := context.Background()
65+ cli, err := docker.NewClient(ctx)
66+ if err != nil {
67+ return err
68+ }
69+ defer cli.Close()
70+ return fn(ctx, cli)
71+}
72+
73+// withBoxLock acquires the named box's non-blocking per-box operation lock
74+// (dossier §8.1: fail fast, never queue two concurrent tx9 operations
75+// against the same box), then runs fn with a connected Docker client. The
76+// lock is released and the client closed on return.
77+func withBoxLock(name string, fn func(ctx context.Context, cli *docker.Client) error) error {
78+ lockPath, err := state.LockPath(name)
79+ if err != nil {
80+ return err
81+ }
82+ release, err := lock.Acquire(lockPath)
83+ if err != nil {
84+ return err
85+ }
86+ defer release()
87+
88+ return withDocker(fn)
89+}
90+
91+// ensureBoxImage builds tag (e.g. "tx9-box:<version>") if the local image
92+// store doesn't already have it. Shared by create and upgrade: both need a
93+// current-version image on hand before creating containers from it, and
94+// the first build is the slow part (~10 min) either way.
95+func ensureBoxImage(ctx context.Context, cli *docker.Client, tag string) error {
96+ haveImage, err := cli.ImageExists(ctx, tag)
97+ if err != nil {
98+ return err
99+ }
100+ if haveImage {
101+ return nil
102+ }
103+ fmt.Printf("tx9: building %s (first build takes ~10 min; cached after)\n", tag)
104+ buildCtx, err := assets.BuildContextTar(BuildContext)
105+ if err != nil {
106+ return err
107+ }
108+ return cli.BuildImage(ctx, buildCtx, tag, os.Stdout)
109+}
internal/cli/box_common_test.go+53−0
@@ -0,0 +1,53 @@
1+package cli
2+
3+import (
4+ "flag"
5+ "io"
6+ "testing"
7+)
8+
9+// Trailing flags (`tx9 delete mybox --force`) were silently ignored when
10+// commands called fs.Parse directly — stdlib flag stops at the first
11+// positional. parseFlagsAnywhere reorders so both spellings work.
12+func TestParseFlagsAnywhere(t *testing.T) {
13+ cases := []struct {
14+ name string
15+ args []string
16+ wantForce bool
17+ wantPath string
18+ wantPos []string
19+ }{
20+ {"flag before positional", []string{"--force", "mybox"}, true, "", []string{"mybox"}},
21+ {"flag after positional", []string{"mybox", "--force"}, true, "", []string{"mybox"}},
22+ {"value flag after positional", []string{"mybox", "--path", "/tmp/x"}, false, "/tmp/x", []string{"mybox"}},
23+ {"value flag equals form", []string{"mybox", "--path=/tmp/y"}, false, "/tmp/y", []string{"mybox"}},
24+ {"double dash stops parsing", []string{"mybox", "--", "--force"}, false, "", []string{"mybox", "--force"}},
25+ {"no flags", []string{"mybox"}, false, "", []string{"mybox"}},
26+ }
27+ for _, tc := range cases {
28+ t.Run(tc.name, func(t *testing.T) {
29+ fs := flag.NewFlagSet("test", flag.ContinueOnError)
30+ fs.SetOutput(io.Discard)
31+ force := fs.Bool("force", false, "")
32+ path := fs.String("path", "", "")
33+ if err := parseFlagsAnywhere(fs, tc.args); err != nil {
34+ t.Fatalf("parse: %v", err)
35+ }
36+ if *force != tc.wantForce {
37+ t.Errorf("force = %v, want %v", *force, tc.wantForce)
38+ }
39+ if *path != tc.wantPath {
40+ t.Errorf("path = %q, want %q", *path, tc.wantPath)
41+ }
42+ if got := fs.Args(); len(got) != len(tc.wantPos) {
43+ t.Fatalf("positionals = %v, want %v", got, tc.wantPos)
44+ } else {
45+ for i := range got {
46+ if got[i] != tc.wantPos[i] {
47+ t.Errorf("positional[%d] = %q, want %q", i, got[i], tc.wantPos[i])
48+ }
49+ }
50+ }
51+ })
52+ }
53+}
internal/cli/cmd_backup.go+333−0
@@ -0,0 +1,333 @@
1+package cli
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "errors"
8+ "flag"
9+ "fmt"
10+ "io"
11+ "os"
12+ "path/filepath"
13+ "strings"
14+ "time"
15+
16+ "golang.org/x/sys/unix"
17+
18+ "github.com/davis7dotsh/tx9/internal/archive"
19+ "github.com/davis7dotsh/tx9/internal/box"
20+ "github.com/davis7dotsh/tx9/internal/docker"
21+ "github.com/davis7dotsh/tx9/internal/version"
22+)
23+
24+// tarExclusions is the exact --exclude set boxd's cmd_save uses (dossier
25+// §6.2), in order: (1) the quiesce marker itself — a restored box must not
26+// start life "already quiesced" for no reason (import re-arms it
27+// deliberately instead, dossier §7.3); (2/3) codex/hermes tmp dirs (dossier
28+// §10 gotcha); (4-6) package manager caches/venvs (bulk, regenerable);
29+// (7/8) compiled Python bytecode; (9-11) live SQLite WAL/SHM/journal
30+// sidecar files (checkpointed separately by `hermes-state verify
31+// --checkpoint` during pause, so excluding them here is safe and avoids
32+// restoring a torn WAL); (12-15) process-lifecycle PID/lock/state files
33+// meaningless outside the process that wrote them.
34+var tarExclusions = []string{
35+ "./home/agent/.config/hermes-box/quiesced",
36+ "./home/agent/.codex/tmp",
37+ "./home/agent/.hermes/tmp",
38+ "*/node_modules",
39+ "*/.venv",
40+ "*/venv",
41+ "*/__pycache__",
42+ "*/.cache",
43+ "*.pyc",
44+ "*.pyo",
45+ "*/.hermes/*.db-wal",
46+ "*/.hermes/*.db-shm",
47+ "*/.hermes/*.db-journal",
48+ "*/gateway.pid",
49+ "*/cron.pid",
50+ "*/gateway.lock",
51+ "*/processes.json",
52+}
53+
54+// buildTarScript reproduces boxd's cmd_save tar invocation verbatim
55+// (dossier §6.2): `tar czf - -C /data <excludes> .`, run inside the
56+// running agent container so /data (the agent-data volume) is captured
57+// exactly as the box sees it, with `sync` first so any just-flushed
58+// writes are on disk before the tar starts.
59+func buildTarScript() string {
60+ var b strings.Builder
61+ b.WriteString("set -euo pipefail; umask 077; sync\n")
62+ b.WriteString("tar czf - -C /data")
63+ for _, ex := range tarExclusions {
64+ fmt.Fprintf(&b, " --exclude=%q", ex)
65+ }
66+ b.WriteString(" .")
67+ return b.String()
68+}
69+
70+// cmdBackup implements `tx9 backup <box>` (alias: export), the Go port of
71+// boxd's cmd_save (dossier §6): quiesce -> tar /data inside the running
72+// agent container -> validate -> encrypt (unless --no-encrypt) -> verify
73+// -> assemble a .tx9 container -> place it atomically -> resume the box if
74+// backup was the one that paused it.
75+func cmdBackup(args []string) error {
76+ fs := flag.NewFlagSet("backup", flag.ContinueOnError)
77+ path := fs.String("path", "", "output directory (default ~/Downloads)")
78+ password := fs.String("password", "", "archive passphrase (else TX9_PASSWORD env, else prompt)")
79+ noEncrypt := fs.Bool("no-encrypt", false, "skip GPG encryption")
80+ if err := parseFlagsAnywhere(fs, args); err != nil {
81+ return err
82+ }
83+ name, err := requireBoxName(fs, "backup")
84+ if err != nil {
85+ return err
86+ }
87+
88+ destDir := *path
89+ if destDir == "" {
90+ home, err := os.UserHomeDir()
91+ if err != nil {
92+ return fmt.Errorf("backup %s: %w", name, err)
93+ }
94+ destDir = filepath.Join(home, "Downloads")
95+ }
96+ if err := os.MkdirAll(destDir, 0o755); err != nil {
97+ return fmt.Errorf("backup %s: create output directory %s: %w", name, destDir, err)
98+ }
99+
100+ encrypted := !*noEncrypt
101+ var passphrase string
102+ if encrypted {
103+ passphrase, err = resolvePassword(*password, true)
104+ if err != nil {
105+ return fmt.Errorf("backup %s: %w", name, err)
106+ }
107+ }
108+
109+ return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error {
110+ b, err := box.Get(ctx, cli, name)
111+ if err != nil {
112+ return fmt.Errorf("backup %s: %w", name, err)
113+ }
114+ if b.AgentID == "" {
115+ return fmt.Errorf("backup %s: agent container missing", name)
116+ }
117+ tok, err := box.Token(name)
118+ if err != nil {
119+ return fmt.Errorf("backup %s: %w", name, err)
120+ }
121+
122+ weQuiesced, err := quiesceForBackup(ctx, cli, b, tok)
123+ if err != nil {
124+ return fmt.Errorf("backup %s: %w", name, err)
125+ }
126+ if weQuiesced {
127+ defer func() {
128+ fmt.Println("tx9: resuming box")
129+ if rerr := box.HB(ctx, cli, b, tok, os.Stdout, os.Stderr, "resume"); rerr != nil {
130+ fmt.Fprintf(os.Stderr, "tx9: warning: failed to resume box %s after backup, resume it manually with `hb resume`: %v\n", name, rerr)
131+ }
132+ }()
133+ }
134+
135+ // boxd's cmd_save always runs these two after quiescing,
136+ // unconditionally (dossier §6 / git show 9a030e2^:docker/boxd
137+ // ~L405-415) — not just when we were the ones who paused. Relying
138+ // solely on `hb pause`'s own checkpoint would skip it whenever
139+ // the box was already paused, and boxd's runtime-manifest write
140+ // never happened at all, so both are run here regardless of
141+ // weQuiesced.
142+ fmt.Println("tx9: checkpointing state")
143+ if err := box.HB(ctx, cli, b, tok, io.Discard, os.Stderr, "verify-state", "--checkpoint"); err != nil {
144+ return fmt.Errorf("backup %s: %w", name, err)
145+ }
146+ if err := box.HB(ctx, cli, b, tok, os.Stdout, os.Stderr, "write-manifest"); err != nil {
147+ return fmt.Errorf("backup %s: %w", name, err)
148+ }
149+
150+ fmt.Println("tx9: capturing /data from the agent container")
151+ rawFile, err := os.CreateTemp("", "tx9-backup-*.tar.gz")
152+ if err != nil {
153+ return fmt.Errorf("backup %s: %w", name, err)
154+ }
155+ rawPath := rawFile.Name()
156+ defer os.Remove(rawPath)
157+
158+ var stderrBuf bytes.Buffer
159+ exitCode, err := cli.ExecStream(ctx, b.AgentID, []string{"bash", "-c", buildTarScript()}, nil, "", rawFile, &stderrBuf)
160+ closeErr := rawFile.Close()
161+ if err != nil {
162+ return fmt.Errorf("backup %s: tar exec: %w", name, err)
163+ }
164+ if closeErr != nil {
165+ return fmt.Errorf("backup %s: %w", name, closeErr)
166+ }
167+ if exitCode != 0 {
168+ return fmt.Errorf("backup %s: tar exited %d: %s", name, exitCode, strings.TrimSpace(stderrBuf.String()))
169+ }
170+
171+ fmt.Println("tx9: validating archive contents")
172+ if err := validateFileAsDataTar(rawPath); err != nil {
173+ return fmt.Errorf("backup %s: refusing to save an unsafe archive: %w", name, err)
174+ }
175+
176+ dataFile := rawPath
177+ if encrypted {
178+ fmt.Println("tx9: encrypting archive")
179+ encPath := rawPath + ".gpg"
180+ defer os.Remove(encPath)
181+ if err := archive.Encrypt(rawPath, encPath, passphrase); err != nil {
182+ return fmt.Errorf("backup %s: %w", name, err)
183+ }
184+ if err := archive.VerifyEncrypted(encPath, passphrase); err != nil {
185+ return fmt.Errorf("backup %s: %w", name, err)
186+ }
187+ dataFile = encPath
188+ }
189+
190+ ts := time.Now()
191+ meta := archive.Metadata{
192+ BoxName: name,
193+ CreatedAt: ts,
194+ CLIVersion: version.Version,
195+ ImageVersion: b.Version,
196+ Encrypted: encrypted,
197+ }
198+
199+ finalName := fmt.Sprintf("%s-%s.tx9", name, ts.Format("20060102-150405"))
200+ finalPath := filepath.Join(destDir, finalName)
201+ stagingPath := filepath.Join(destDir, fmt.Sprintf(".%s.staging-%d", finalName, os.Getpid()))
202+
203+ if err := writeTx9Staged(stagingPath, meta, dataFile); err != nil {
204+ os.Remove(stagingPath)
205+ return fmt.Errorf("backup %s: %w", name, err)
206+ }
207+ // ln (hard link), not mv/cp: fails loudly if finalPath already
208+ // exists rather than silently clobbering a prior backup of the
209+ // same name (dossier §6.5's atomic no-clobber placement).
210+ if err := os.Link(stagingPath, finalPath); err != nil {
211+ os.Remove(stagingPath)
212+ return fmt.Errorf("backup %s: place archive at %s: %w", name, finalPath, err)
213+ }
214+ os.Remove(stagingPath)
215+
216+ info, statErr := os.Stat(finalPath)
217+ if statErr != nil {
218+ fmt.Printf("tx9: backup complete: %s\n", finalPath)
219+ } else {
220+ fmt.Printf("tx9: backup complete: %s (%d bytes)\n", finalPath, info.Size())
221+ }
222+ return nil
223+ })
224+}
225+
226+// quiesceForBackup implements dossier §6.1: check `hb is-paused`; if not
227+// already paused, call `hb pause` and report that the caller must resume
228+// afterward. A non-nil error from `is-paused` is treated as "not paused"
229+// (hb's own is-paused check exits non-zero in that case, mirroring boxd's
230+// `_guest_hb` usage).
231+func quiesceForBackup(ctx context.Context, cli *docker.Client, b *box.Box, token string) (weQuiesced bool, err error) {
232+ paused := box.HB(ctx, cli, b, token, nil, nil, "is-paused") == nil
233+ if paused {
234+ return false, nil
235+ }
236+ fmt.Println("tx9: pausing box for a consistent snapshot")
237+ if err := box.HB(ctx, cli, b, token, os.Stdout, os.Stderr, "pause"); err != nil {
238+ return false, fmt.Errorf("pause: %w", err)
239+ }
240+ return true, nil
241+}
242+
243+// validateFileAsDataTar opens path and runs archive.ValidateDataTar over it.
244+func validateFileAsDataTar(path string) error {
245+ f, err := os.Open(path)
246+ if err != nil {
247+ return err
248+ }
249+ defer f.Close()
250+ return archive.ValidateDataTar(f)
251+}
252+
253+// writeTx9Staged assembles a complete .tx9 container at stagingPath using
254+// archive.WriteTx9, so the potentially-slow copy/gzip-framing work happens
255+// on a name nothing else can be watching before the atomic hard-link
256+// promotion in cmdBackup.
257+func writeTx9Staged(stagingPath string, meta archive.Metadata, dataFile string) error {
258+ out, err := os.OpenFile(stagingPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
259+ if err != nil {
260+ return fmt.Errorf("stage archive: %w", err)
261+ }
262+ writeErr := archive.WriteTx9(out, meta, dataFile)
263+ closeErr := out.Close()
264+ if writeErr != nil {
265+ return fmt.Errorf("stage archive: %w", writeErr)
266+ }
267+ if closeErr != nil {
268+ return fmt.Errorf("stage archive: %w", closeErr)
269+ }
270+ return nil
271+}
272+
273+// readPassword prompts on /dev/tty with echo disabled (dossier §6.4's
274+// interactive-prompt fallback), toggling ECHO off / ECHONL on exactly like
275+// a standard `stty -echo` password prompt, and always restoring the
276+// original termios settings afterward.
277+func readPassword(prompt string) (string, error) {
278+ tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
279+ if err != nil {
280+ return "", fmt.Errorf("read password: open /dev/tty: %w", err)
281+ }
282+ defer tty.Close()
283+
284+ fd := int(tty.Fd())
285+ oldState, err := unix.IoctlGetTermios(fd, unix.TCGETS)
286+ if err != nil {
287+ return "", fmt.Errorf("read password: get termios: %w", err)
288+ }
289+ newState := *oldState
290+ newState.Lflag &^= unix.ECHO
291+ newState.Lflag |= unix.ECHONL
292+ if err := unix.IoctlSetTermios(fd, unix.TCSETS, &newState); err != nil {
293+ return "", fmt.Errorf("read password: set termios: %w", err)
294+ }
295+ defer unix.IoctlSetTermios(fd, unix.TCSETS, oldState)
296+
297+ fmt.Fprint(tty, prompt)
298+ line, err := bufio.NewReader(tty).ReadString('\n')
299+ if err != nil && !errors.Is(err, io.EOF) {
300+ return "", fmt.Errorf("read password: %w", err)
301+ }
302+ return strings.TrimRight(line, "\r\n"), nil
303+}
304+
305+// resolvePassword implements the flag > env > interactive-prompt
306+// precedence used by both backup and import ("--password" flag, else
307+// TX9_PASSWORD env, else prompt). When confirm is true (backup minting a
308+// new passphrase), the operator must type it twice matching.
309+func resolvePassword(flagVal string, confirm bool) (string, error) {
310+ if flagVal != "" {
311+ return flagVal, nil
312+ }
313+ if env := os.Getenv("TX9_PASSWORD"); env != "" {
314+ return env, nil
315+ }
316+ pw, err := readPassword("Archive passphrase: ")
317+ if err != nil {
318+ return "", err
319+ }
320+ if pw == "" {
321+ return "", errors.New("archive passphrase must not be empty")
322+ }
323+ if confirm {
324+ pw2, err := readPassword("Confirm passphrase: ")
325+ if err != nil {
326+ return "", err
327+ }
328+ if pw2 != pw {
329+ return "", errors.New("passphrases did not match")
330+ }
331+ }
332+ return pw, nil
333+}
internal/cli/cmd_create.go+112−0
@@ -0,0 +1,112 @@
1+package cli
2+
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "os"
8+
9+ "github.com/davis7dotsh/tx9/internal/box"
10+ "github.com/davis7dotsh/tx9/internal/docker"
11+ "github.com/davis7dotsh/tx9/internal/lock"
12+ "github.com/davis7dotsh/tx9/internal/names"
13+ "github.com/davis7dotsh/tx9/internal/state"
14+ "github.com/davis7dotsh/tx9/internal/token"
15+ "github.com/davis7dotsh/tx9/internal/version"
16+)
17+
18+// cmdCreate implements `tx9 create [name]` (command surface: generate a
19+// friendly name if absent, build tx9-box:<version> if missing, create
20+// network + volumes + both containers, mint token, wire MCP, run doctor).
21+func cmdCreate(args []string) error {
22+ fs := flag.NewFlagSet("create", flag.ContinueOnError)
23+ if err := parseFlagsAnywhere(fs, args); err != nil {
24+ return err
25+ }
26+ if fs.NArg() > 1 {
27+ return fmt.Errorf("create: expected at most one box name (usage: tx9 create [box])")
28+ }
29+ requested := fs.Arg(0)
30+
31+ ctx := context.Background()
32+ cli, err := docker.NewClient(ctx)
33+ if err != nil {
34+ return err
35+ }
36+ defer cli.Close()
37+
38+ name := requested
39+ if name == "" {
40+ existing, err := box.List(ctx, cli)
41+ if err != nil {
42+ return fmt.Errorf("create: %w", err)
43+ }
44+ taken := make(map[string]bool, len(existing))
45+ for _, b := range existing {
46+ taken[b.Name] = true
47+ }
48+ name, err = names.Generate(taken)
49+ if err != nil {
50+ return fmt.Errorf("create: %w", err)
51+ }
52+ } else if err := names.Validate(name); err != nil {
53+ return fmt.Errorf("create: %w", err)
54+ }
55+
56+ lockPath, err := state.LockPath(name)
57+ if err != nil {
58+ return fmt.Errorf("create %s: %w", name, err)
59+ }
60+ release, err := lock.Acquire(lockPath)
61+ if err != nil {
62+ return fmt.Errorf("create %s: %w", name, err)
63+ }
64+ defer release()
65+
66+ exists, err := box.Exists(ctx, cli, name)
67+ if err != nil {
68+ return fmt.Errorf("create %s: %w", name, err)
69+ }
70+ if exists {
71+ return fmt.Errorf("create %s: box already exists", name)
72+ }
73+
74+ imageTag := fmt.Sprintf("tx9-box:%s", version.Version)
75+ if err := ensureBoxImage(ctx, cli, imageTag); err != nil {
76+ return fmt.Errorf("create %s: %w", name, err)
77+ }
78+
79+ tok, err := token.Mint()
80+ if err != nil {
81+ return fmt.Errorf("create %s: %w", name, err)
82+ }
83+ if err := state.WriteBoxEnv(name, map[string]string{"EXECUTOR_MCP_TOKEN": tok}); err != nil {
84+ return fmt.Errorf("create %s: %w", name, err)
85+ }
86+
87+ fmt.Printf("tx9: creating box %q (agent + executor containers, private network)\n", name)
88+ b, err := box.Create(ctx, cli, box.CreateOpts{Name: name, Image: imageTag, Token: tok})
89+ if err != nil {
90+ box.Destroy(ctx, cli, name)
91+ return fmt.Errorf("create %s: %w", name, err)
92+ }
93+
94+ fmt.Println("tx9: preparing runtime (executor reachability, MCP wiring, doctor)")
95+ if err := box.PrepareRuntime(ctx, cli, b, tok, os.Stdout); err != nil {
96+ box.Destroy(ctx, cli, name)
97+ return fmt.Errorf("create %s: runtime failed, box removed: %w", name, err)
98+ }
99+
100+ port, err := box.HostPort(ctx, cli, b)
101+ if err != nil {
102+ return fmt.Errorf("create %s: box is up but %w", name, err)
103+ }
104+
105+ fmt.Printf("\nBox %q is ready. Next steps:\n", name)
106+ fmt.Printf(" 1. tx9 enter %s\n", name)
107+ fmt.Println(" 2. inside the box, authenticate: claude (Claude Code) and codex")
108+ fmt.Println(" 3. when ready to go live, run (the only path that starts the gateway):")
109+ fmt.Println(" hb gateway-enable --confirm-single-writer I_CONFIRM_NO_OTHER_GATEWAY_USES_THIS_IDENTITY")
110+ fmt.Printf(" 4. dashboard: %s (run `tx9 open %s` for an authenticated one-shot URL)\n", box.DashboardURL(port), name)
111+ return nil
112+}
internal/cli/cmd_delete.go+63−0
@@ -0,0 +1,63 @@
1+package cli
2+
3+import (
4+ "bufio"
5+ "context"
6+ "flag"
7+ "fmt"
8+ "os"
9+ "strings"
10+
11+ "github.com/davis7dotsh/tx9/internal/box"
12+ "github.com/davis7dotsh/tx9/internal/docker"
13+)
14+
15+// cmdDelete implements `tx9 delete <box>`: containers + volumes + token
16+// file, typed-name confirmation (--force to skip).
17+func cmdDelete(args []string) error {
18+ fs := flag.NewFlagSet("delete", flag.ContinueOnError)
19+ force := fs.Bool("force", false, "skip typed-name confirmation")
20+ if err := parseFlagsAnywhere(fs, args); err != nil {
21+ return err
22+ }
23+ name, err := requireBoxName(fs, "delete")
24+ if err != nil {
25+ return err
26+ }
27+
28+ if !*force {
29+ if err := confirmName(name); err != nil {
30+ return err
31+ }
32+ }
33+
34+ return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error {
35+ if exists, err := box.Exists(ctx, cli, name); err != nil {
36+ return fmt.Errorf("delete %s: %w", name, err)
37+ } else if !exists {
38+ return fmt.Errorf("delete %s: box does not exist", name)
39+ }
40+ if err := box.Destroy(ctx, cli, name); err != nil {
41+ return fmt.Errorf("delete %s: %w", name, err)
42+ }
43+ fmt.Printf("tx9: box %s deleted\n", name)
44+ return nil
45+ })
46+}
47+
48+// confirmName prompts on stdin and requires the operator to type the exact
49+// box name back, a deliberate "destructive-by-default" friction distinct
50+// from a plain --yes flag (mirrors gateway_enable's confirm-phrase pattern,
51+// dossier §9, though delete's confirmation is the name itself).
52+func confirmName(name string) error {
53+ fmt.Printf("Type the box name to confirm deletion of %q: ", name)
54+ reader := bufio.NewReader(os.Stdin)
55+ line, err := reader.ReadString('\n')
56+ if err != nil {
57+ return fmt.Errorf("delete %s: could not read confirmation: %w", name, err)
58+ }
59+ if strings.TrimSpace(line) != name {
60+ return fmt.Errorf("delete %s: confirmation did not match, aborting", name)
61+ }
62+ return nil
63+}
internal/cli/cmd_doctor.go+101−0
@@ -0,0 +1,101 @@
1+package cli
2+
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "net"
8+ "net/http"
9+ "os"
10+ "time"
11+
12+ "github.com/davis7dotsh/tx9/internal/box"
13+ "github.com/davis7dotsh/tx9/internal/docker"
14+)
15+
16+// cmdDoctor implements `tx9 doctor <box>`: in-box `hb doctor` + host-side
17+// published-port probe (dossier §5). Both must pass.
18+func cmdDoctor(args []string) error {
19+ fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
20+ if err := parseFlagsAnywhere(fs, args); err != nil {
21+ return err
22+ }
23+ name, err := requireBoxName(fs, "doctor")
24+ if err != nil {
25+ return err
26+ }
27+
28+ return withDocker(func(ctx context.Context, cli *docker.Client) error {
29+ b, err := box.Get(ctx, cli, name)
30+ if err != nil {
31+ return fmt.Errorf("doctor %s: %w", name, err)
32+ }
33+ // Use the aggregate state, not just AgentState: if the executor
34+ // container is down while the agent is up, DerivedState() != "running"
35+ // catches it here instead of burning the entire 6x3s host-probe
36+ // loop below only to fail anyway.
37+ if b.DerivedState() != "running" {
38+ return fmt.Errorf("doctor %s: box is not running; run: tx9 start %s", name, name)
39+ }
40+
41+ tok, err := box.Token(name)
42+ if err != nil {
43+ return fmt.Errorf("doctor %s: %w", name, err)
44+ }
45+
46+ guestErr := box.HB(ctx, cli, b, tok, os.Stdout, os.Stderr, "doctor")
47+
48+ port, portErr := box.HostPort(ctx, cli, b)
49+ var probeErr error
50+ if portErr != nil {
51+ probeErr = fmt.Errorf("host-side probe: %w", portErr)
52+ } else {
53+ probeErr = probeHostPort(port)
54+ if probeErr == nil {
55+ fmt.Printf("ok host executor endpoint reachable on port %s\n", port)
56+ }
57+ }
58+
59+ if guestErr != nil || probeErr != nil {
60+ return fmt.Errorf("doctor %s: guest=%v host=%v", name, guestErr, probeErr)
61+ }
62+ fmt.Printf("doctor passed for %s\n", name)
63+ return nil
64+ })
65+}
66+
67+// probeHostPort mirrors boxd's cmd_doctor host-side retry loop (dossier
68+// §5.2): up to 6 attempts, 3s connect timeout, 3s sleep between attempts —
69+// the executor daemon needs a few seconds after `start` before it's
70+// actually listening (dossier §10 gotcha #2).
71+func probeHostPort(port string) error {
72+ const attempts = 6
73+ const connectTimeout = 3 * time.Second
74+ const retryWait = 3 * time.Second
75+
76+ url := fmt.Sprintf("http://127.0.0.1:%s/", port)
77+ client := &http.Client{
78+ Timeout: connectTimeout,
79+ Transport: &http.Transport{
80+ DialContext: (&net.Dialer{Timeout: connectTimeout}).DialContext,
81+ },
82+ }
83+
84+ var lastErr error
85+ for attempt := 1; attempt <= attempts; attempt++ {
86+ resp, err := client.Get(url)
87+ if err == nil {
88+ resp.Body.Close()
89+ if resp.StatusCode < 400 {
90+ return nil
91+ }
92+ lastErr = fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url)
93+ } else {
94+ lastErr = err
95+ }
96+ if attempt < attempts {
97+ time.Sleep(retryWait)
98+ }
99+ }
100+ return fmt.Errorf("executor dashboard unreachable on 127.0.0.1:%s after %d attempts: %w", port, attempts, lastErr)
101+}
internal/cli/cmd_enter.go+82−0
@@ -0,0 +1,82 @@
1+package cli
2+
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "syscall"
10+
11+ "github.com/davis7dotsh/tx9/internal/box"
12+ "github.com/davis7dotsh/tx9/internal/docker"
13+)
14+
15+// cmdEnter implements `tx9 enter <box>` (alias `ssh`): exec into the agent
16+// container as `agent`, dropping into the box's persistent tmux `main`
17+// session. Starts the box first if it isn't already running (no readiness
18+// retry loop here — that's `create`/`upgrade`'s job; enter just needs the
19+// containers up so `docker exec` has somewhere to attach).
20+//
21+// This replicates boxd's own `cmd_enter` exactly (recovered from git history
22+// at 6f1ced3, docker/boxd): `docker exec -it -u agent ... bash -l`. The
23+// login shell is what triggers guest/agent-bash-profile.sh, which runs
24+// `hb up`/`hb wire-once` and then execs into the tmux `main` session — there
25+// is no separate `hb enter` subcommand to invoke.
26+func cmdEnter(args []string) error {
27+ fs := flag.NewFlagSet("enter", flag.ContinueOnError)
28+ if err := parseFlagsAnywhere(fs, args); err != nil {
29+ return err
30+ }
31+ name, err := requireBoxName(fs, "enter")
32+ if err != nil {
33+ return err
34+ }
35+
36+ var agentID, tok string
37+ err = withBoxLock(name, func(ctx context.Context, cli *docker.Client) error {
38+ b, err := box.Get(ctx, cli, name)
39+ if err != nil {
40+ return fmt.Errorf("enter %s: %w", name, err)
41+ }
42+ if b.AgentState != "running" || b.ExecutorState != "running" {
43+ if err := box.Start(ctx, cli, b); err != nil {
44+ return fmt.Errorf("enter %s: start: %w", name, err)
45+ }
46+ }
47+ tok, err = box.Token(name)
48+ if err != nil {
49+ return fmt.Errorf("enter %s: %w", name, err)
50+ }
51+ agentID = b.AgentID
52+ return nil
53+ })
54+ if err != nil {
55+ return err
56+ }
57+
58+ dockerBin, err := exec.LookPath("docker")
59+ if err != nil {
60+ return fmt.Errorf("enter %s: docker binary not found on PATH: %w", name, err)
61+ }
62+
63+ argv := []string{
64+ dockerBin, "exec", "-it", "-u", "agent",
65+ "-e", "HOME=/data/home/agent",
66+ "-e", "USER=agent",
67+ "-e", "LOGNAME=agent",
68+ "-e", "SHELL=/bin/bash",
69+ "-e", "EXECUTOR_HOST=executor",
70+ "-e", "BOXD_EXECUTOR_TOKEN=" + tok,
71+ "-w", "/data/home/agent",
72+ agentID, "bash", "-l",
73+ }
74+
75+ // syscall.Exec replaces this process outright so the TTY (job control,
76+ // signals, resize) is docker's problem from here on, exactly as boxd's
77+ // own `exec docker exec ...` did.
78+ if err := syscall.Exec(dockerBin, argv, os.Environ()); err != nil {
79+ return fmt.Errorf("enter %s: exec docker: %w", name, err)
80+ }
81+ return nil
82+}
internal/cli/cmd_import.go+195−0
@@ -0,0 +1,195 @@
1+package cli
2+
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "os"
8+
9+ "github.com/davis7dotsh/tx9/internal/archive"
10+ "github.com/davis7dotsh/tx9/internal/box"
11+ "github.com/davis7dotsh/tx9/internal/docker"
12+ "github.com/davis7dotsh/tx9/internal/names"
13+ "github.com/davis7dotsh/tx9/internal/state"
14+ "github.com/davis7dotsh/tx9/internal/token"
15+ "github.com/davis7dotsh/tx9/internal/version"
16+)
17+
18+// restoreStageScript is boxd's stage-then-promote restore script, lifted
19+// verbatim (dossier §7.2), run inside a throwaway container that mounts
20+// the freshly-created (but not-yet-started) box's agent-data volume
21+// directly at /data. It wipes /data, extracts into a staging subdirectory,
22+// sanity-checks the tree, fixes ownership, re-arms the quiesce/gateway
23+// markers (dossier §7.3 — a restored box must always arrive quiesced and
24+// gateway-disabled regardless of the source box's state when it was
25+// saved), runs a last-chance hermes-state integrity check against the
26+// staged (not yet live) Hermes home, and only then promotes the staged
27+// tree up into /data itself.
28+const restoreStageScript = `set -euo pipefail
29+find /data -mindepth 1 -delete
30+stage=/data/.hb-restore-stage
31+mkdir -m 0700 "$stage"
32+tar xzf - --no-same-owner -C "$stage"
33+test -d "$stage/home/agent"
34+chown root:root "$stage" "$stage/home"
35+chmod 0711 "$stage" "$stage/home"
36+chown -R agent:agent "$stage/home/agent" "$stage/logs" 2>/dev/null || true
37+mkdir -p "$stage/home/agent/.config/hermes-box"
38+touch "$stage/home/agent/.config/hermes-box/quiesced" \
39+ "$stage/home/agent/.config/hermes-box/gateway-disabled" \
40+ "$stage/home/agent/.config/hermes-box/gateway-policy-initialized"
41+chown -R agent:agent "$stage/home/agent/.config/hermes-box"
42+runuser -u agent -- env HOME=/data/home/agent \
43+ /opt/hermes-box/bin/hermes-state verify --home "$stage/home/agent/.hermes" >/dev/null
44+shopt -s dotglob
45+mv "$stage"/* /data/
46+rmdir "$stage"
47+chown root:root /data && chmod 0711 /data
48+`
49+
50+// cmdImport implements `tx9 import <file.tx9>`, the Go port of boxd's
51+// cmd_load (dossier §7): read metadata (plaintext, before touching the
52+// possibly-encrypted payload) -> resolve target name -> hard fail on
53+// collision -> decrypt+validate BEFORE creating anything -> mint a fresh
54+// token -> create network/volumes/containers without starting them ->
55+// stage-then-promote restore via a throwaway container -> start the box ->
56+// verify-state (not the full runtime-prep gate, since that would try to
57+// wire MCP against a box the restore deliberately leaves quiesced).
58+func cmdImport(args []string) error {
59+ fs := flag.NewFlagSet("import", flag.ContinueOnError)
60+ nameOverride := fs.String("name", "", "box name to restore under (default: from archive metadata)")
61+ password := fs.String("password", "", "archive passphrase (else TX9_PASSWORD env, else prompt)")
62+ if err := parseFlagsAnywhere(fs, args); err != nil {
63+ return err
64+ }
65+ if fs.NArg() < 1 {
66+ return fmt.Errorf("import: archive file required (usage: tx9 import <file.tx9>)")
67+ }
68+ archivePath := fs.Arg(0)
69+
70+ meta, err := archive.ReadMetadata(archivePath)
71+ if err != nil {
72+ return fmt.Errorf("import %s: %w", archivePath, err)
73+ }
74+
75+ name := *nameOverride
76+ if name == "" {
77+ name = meta.BoxName
78+ }
79+ if err := names.Validate(name); err != nil {
80+ return fmt.Errorf("import %s: %w", archivePath, err)
81+ }
82+
83+ var passphrase string
84+ if meta.Encrypted {
85+ passphrase, err = resolvePassword(*password, false)
86+ if err != nil {
87+ return fmt.Errorf("import %s: %w", archivePath, err)
88+ }
89+ }
90+
91+ return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error {
92+ if exists, err := box.Exists(ctx, cli, name); err != nil {
93+ return fmt.Errorf("import %s: %w", name, err)
94+ } else if exists {
95+ return fmt.Errorf("import %s: box %q already exists, choose a different --name or delete it first", name, name)
96+ }
97+
98+ tmpDir, err := os.MkdirTemp("", "tx9-import-*")
99+ if err != nil {
100+ return fmt.Errorf("import %s: %w", name, err)
101+ }
102+ defer os.RemoveAll(tmpDir)
103+
104+ fmt.Println("tx9: extracting archive payload")
105+ rawPath := tmpDir + "/data.payload"
106+ if _, err := archive.ExtractData(archivePath, rawPath); err != nil {
107+ return fmt.Errorf("import %s: %w", name, err)
108+ }
109+
110+ dataTarGzPath := rawPath
111+ if meta.Encrypted {
112+ fmt.Println("tx9: decrypting archive")
113+ decPath := tmpDir + "/data.tar.gz"
114+ if err := archive.Decrypt(rawPath, decPath, passphrase); err != nil {
115+ return fmt.Errorf("import %s: %w", name, err)
116+ }
117+ dataTarGzPath = decPath
118+ }
119+
120+ fmt.Println("tx9: validating archive contents")
121+ if err := validateFileAsDataTar(dataTarGzPath); err != nil {
122+ return fmt.Errorf("import %s: archive failed validation, refusing to restore: %w", name, err)
123+ }
124+
125+ tok, err := token.Mint()
126+ if err != nil {
127+ return fmt.Errorf("import %s: %w", name, err)
128+ }
129+ if err := state.WriteBoxEnv(name, map[string]string{"EXECUTOR_MCP_TOKEN": tok}); err != nil {
130+ return fmt.Errorf("import %s: %w", name, err)
131+ }
132+
133+ imageTag := fmt.Sprintf("tx9-box:%s", version.Version)
134+ if err := ensureBoxImage(ctx, cli, imageTag); err != nil {
135+ return fmt.Errorf("import %s: %w", name, err)
136+ }
137+
138+ fmt.Printf("tx9: creating box %q (network, volumes, containers — not started yet)\n", name)
139+ b, err := box.Create(ctx, cli, box.CreateOpts{Name: name, Image: imageTag, Token: tok, NoStart: true})
140+ if err != nil {
141+ box.Destroy(ctx, cli, name)
142+ return fmt.Errorf("import %s: %w", name, err)
143+ }
144+
145+ fmt.Println("tx9: restoring data into the agent volume (stage-then-promote)")
146+ agentVol, _ := box.VolumeNames(name)
147+ sf, err := os.Open(dataTarGzPath)
148+ if err != nil {
149+ box.Destroy(ctx, cli, name)
150+ return fmt.Errorf("import %s: %w", name, err)
151+ }
152+ exitCode, err := cli.RunEphemeral(ctx, docker.EphemeralOpts{
153+ Image: imageTag,
154+ Entrypoint: []string{"bash"},
155+ Cmd: []string{"-c", restoreStageScript},
156+ Binds: []string{agentVol + ":/data"},
157+ Stdin: sf,
158+ Stdout: os.Stdout,
159+ Stderr: os.Stderr,
160+ })
161+ sf.Close()
162+ if err != nil {
163+ box.Destroy(ctx, cli, name)
164+ return fmt.Errorf("import %s: stage-then-promote restore: %w", name, err)
165+ }
166+ if exitCode != 0 {
167+ box.Destroy(ctx, cli, name)
168+ return fmt.Errorf("import %s: stage-then-promote restore exited %d", name, exitCode)
169+ }
170+
171+ fmt.Println("tx9: starting box")
172+ if err := box.Start(ctx, cli, b); err != nil {
173+ box.Destroy(ctx, cli, name)
174+ return fmt.Errorf("import %s: %w", name, err)
175+ }
176+
177+ // Deliberately NOT box.PrepareRuntime: it unconditionally runs
178+ // `hb wire-once`, which would try to wire MCP against a box the
179+ // restore just arranged to arrive quiesced + gateway-disabled
180+ // (dossier §7.3). verify-state confirms the container/daemon
181+ // stack is healthy without touching that contract; a failure here
182+ // is a warning, not a reason to tear the box back down, since the
183+ // restore itself already succeeded.
184+ if err := box.HB(ctx, cli, b, tok, os.Stdout, os.Stderr, "verify-state"); err != nil {
185+ fmt.Fprintf(os.Stderr, "tx9: warning: post-import verify-state reported a problem, inspect the box before trusting it: %v\n", err)
186+ }
187+
188+ fmt.Printf("\ntx9: box %q restored from %s.\n", name, archivePath)
189+ fmt.Println("It has arrived quiesced and with the Hermes gateway disabled (single-writer safety gate). Next steps:")
190+ fmt.Printf(" 1. tx9 enter %s\n", name)
191+ fmt.Println(" 2. hb resume")
192+ fmt.Println(" 3. hb gateway-enable --confirm-single-writer I_CONFIRM_NO_OTHER_GATEWAY_USES_THIS_IDENTITY")
193+ return nil
194+ })
195+}
internal/cli/cmd_list.go+63−0
@@ -0,0 +1,63 @@
1+package cli
2+
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "os"
8+ "text/tabwriter"
9+
10+ "github.com/davis7dotsh/tx9/internal/box"
11+ "github.com/davis7dotsh/tx9/internal/docker"
12+ "github.com/davis7dotsh/tx9/internal/version"
13+)
14+
15+// cmdList implements `tx9 list` (command surface: all boxes on this
16+// machine from daemon labels — state, image version vs CLI version drift,
17+// dashboard URL).
18+func cmdList(args []string) error {
19+ fs := flag.NewFlagSet("list", flag.ContinueOnError)
20+ if err := parseFlagsAnywhere(fs, args); err != nil {
21+ return err
22+ }
23+
24+ return withDocker(func(ctx context.Context, cli *docker.Client) error {
25+ boxes, err := box.List(ctx, cli)
26+ if err != nil {
27+ return fmt.Errorf("list: %w", err)
28+ }
29+ if len(boxes) == 0 {
30+ fmt.Println("no boxes (tx9 create to make one)")
31+ return nil
32+ }
33+
34+ w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
35+ fmt.Fprintln(w, "NAME\tSTATE\tIMAGE VERSION\tURL")
36+ for _, b := range boxes {
37+ state := b.DerivedState()
38+
39+ url := "-"
40+ if state == "running" {
41+ if port, err := box.HostPort(ctx, cli, &b); err == nil {
42+ url = box.DashboardURL(port)
43+ }
44+ }
45+
46+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", b.Name, state, imageVersionDisplay(b.Version), url)
47+ }
48+ return w.Flush()
49+ })
50+}
51+
52+// imageVersionDisplay formats a box's tx9.version label against the
53+// running CLI's own version, flagging drift the way `tx9 upgrade <box>`
54+// would resolve (spec: "0.1.0 (cli: 0.2.0)" when they differ).
55+func imageVersionDisplay(boxVersion string) string {
56+ if boxVersion == "" {
57+ return "?"
58+ }
59+ if boxVersion == version.Version {
60+ return boxVersion
61+ }
62+ return fmt.Sprintf("%s (cli: %s)", boxVersion, version.Version)
63+}
internal/cli/cmd_list_test.go+31−0
@@ -0,0 +1,31 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/davis7dotsh/tx9/internal/version"
7+)
8+
9+func TestImageVersionDisplay(t *testing.T) {
10+ cases := []struct {
11+ name string
12+ boxVersion string
13+ want string
14+ }{
15+ {name: "empty label", boxVersion: "", want: "?"},
16+ {name: "matches CLI version", boxVersion: version.Version, want: version.Version},
17+ {
18+ name: "drifted from CLI version",
19+ boxVersion: "0.1.0-not-the-cli-version",
20+ want: "0.1.0-not-the-cli-version (cli: " + version.Version + ")",
21+ },
22+ }
23+
24+ for _, tc := range cases {
25+ t.Run(tc.name, func(t *testing.T) {
26+ if got := imageVersionDisplay(tc.boxVersion); got != tc.want {
27+ t.Errorf("imageVersionDisplay(%q) = %q, want %q", tc.boxVersion, got, tc.want)
28+ }
29+ })
30+ }
31+}
internal/cli/cmd_open.go+70−0
@@ -0,0 +1,70 @@
1+package cli
2+
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+
10+ "github.com/davis7dotsh/tx9/internal/box"
11+ "github.com/davis7dotsh/tx9/internal/docker"
12+)
13+
14+// cmdOpen implements `tx9 open <box>`: print (or open) the authenticated
15+// dashboard URL (?_token=). Mirrors boxd's cmd_open (dossier §4): the
16+// token-bearing URL is a one-shot secret, distinct from `create`'s printed
17+// (unauthenticated) dashboard URL.
18+func cmdOpen(args []string) error {
19+ fs := flag.NewFlagSet("open", flag.ContinueOnError)
20+ if err := parseFlagsAnywhere(fs, args); err != nil {
21+ return err
22+ }
23+ name, err := requireBoxName(fs, "open")
24+ if err != nil {
25+ return err
26+ }
27+
28+ return withDocker(func(ctx context.Context, cli *docker.Client) error {
29+ b, err := box.Get(ctx, cli, name)
30+ if err != nil {
31+ return fmt.Errorf("open %s: %w", name, err)
32+ }
33+ if b.DerivedState() != "running" {
34+ return fmt.Errorf("open %s: box is not running; run: tx9 start %s", name, name)
35+ }
36+
37+ port, err := box.HostPort(ctx, cli, b)
38+ if err != nil {
39+ return fmt.Errorf("open %s: %w", name, err)
40+ }
41+ tok, err := box.Token(name)
42+ if err != nil {
43+ return fmt.Errorf("open %s: %w", name, err)
44+ }
45+
46+ url := box.OpenURL(port, tok)
47+
48+ if browser := firstNonEmpty(os.Getenv("TX9_BROWSER"), os.Getenv("BOX_BROWSER")); browser != "" {
49+ if _, lookErr := exec.LookPath(browser); lookErr == nil {
50+ if runErr := exec.Command(browser, url).Start(); runErr == nil {
51+ fmt.Printf("tx9: opened dashboard for %s\n", name)
52+ return nil
53+ }
54+ }
55+ }
56+
57+ fmt.Println("tx9: dashboard URL — this embeds a bearer token, treat it as a secret:")
58+ fmt.Println(url)
59+ return nil
60+ })
61+}
62+
63+func firstNonEmpty(vals ...string) string {
64+ for _, v := range vals {
65+ if v != "" {
66+ return v
67+ }
68+ }
69+ return ""
70+}
internal/cli/cmd_prune.go+168−0
@@ -0,0 +1,168 @@
1+package cli
2+
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/davis7dotsh/tx9/internal/box"
12+ "github.com/davis7dotsh/tx9/internal/docker"
13+ "github.com/davis7dotsh/tx9/internal/lock"
14+ "github.com/davis7dotsh/tx9/internal/state"
15+ "github.com/davis7dotsh/tx9/internal/version"
16+)
17+
18+// cmdPrune implements `tx9 prune`: garbage-collect stale tx9-box:* images
19+// (superseded versions no container references) and orphaned per-box state
20+// files (~/.tx9/boxes/*.env with no matching box left in the daemon).
21+// Unlike other lifecycle commands, prune spans every box at once, so it
22+// doesn't take a per-box lock — it only ever removes things nothing is
23+// using.
24+func cmdPrune(args []string) error {
25+ fs := flag.NewFlagSet("prune", flag.ContinueOnError)
26+ if err := parseFlagsAnywhere(fs, args); err != nil {
27+ return err
28+ }
29+
30+ return withDocker(func(ctx context.Context, cli *docker.Client) error {
31+ removedImages, err := pruneImages(ctx, cli)
32+ if err != nil {
33+ return fmt.Errorf("prune: %w", err)
34+ }
35+ removedEnvFiles, err := pruneStateFiles(ctx, cli)
36+ if err != nil {
37+ return fmt.Errorf("prune: %w", err)
38+ }
39+
40+ if len(removedImages) == 0 && len(removedEnvFiles) == 0 {
41+ fmt.Println("tx9: nothing to prune")
42+ return nil
43+ }
44+ for _, tag := range removedImages {
45+ fmt.Printf("tx9: removed unused image %s\n", tag)
46+ }
47+ for _, path := range removedEnvFiles {
48+ fmt.Printf("tx9: removed stale state file %s\n", path)
49+ }
50+ return nil
51+ })
52+}
53+
54+// pruneImages removes tx9-box:* images whose version tag isn't the
55+// running CLI's own version AND aren't referenced by any container tx9
56+// still knows about (running or stopped — a stopped box still "uses" its
57+// image). The current version's image is always kept even if unused,
58+// since it's what the next `create`/`upgrade` needs.
59+func pruneImages(ctx context.Context, cli *docker.Client) ([]string, error) {
60+ images, err := cli.ImageList(ctx, "tx9-box:*")
61+ if err != nil {
62+ return nil, err
63+ }
64+
65+ containers, err := cli.ListBoxContainers(ctx)
66+ if err != nil {
67+ return nil, err
68+ }
69+ inUse := map[string]bool{}
70+ for _, c := range containers {
71+ inUse[c.Image] = true
72+ inUse[c.ImageID] = true
73+ }
74+
75+ currentTag := fmt.Sprintf("tx9-box:%s", version.Version)
76+
77+ var removed []string
78+ for _, img := range images {
79+ if inUse[img.ID] {
80+ continue
81+ }
82+
83+ keep := false
84+ usedByTag := false
85+ for _, tag := range img.RepoTags {
86+ if tag == currentTag {
87+ keep = true
88+ }
89+ if inUse[tag] {
90+ usedByTag = true
91+ }
92+ }
93+ if keep || usedByTag {
94+ continue
95+ }
96+
97+ if err := cli.ImageRemove(ctx, img.ID); err != nil {
98+ return removed, err
99+ }
100+ if len(img.RepoTags) > 0 {
101+ removed = append(removed, img.RepoTags[0])
102+ } else {
103+ removed = append(removed, img.ID)
104+ }
105+ }
106+ return removed, nil
107+}
108+
109+// pruneStateFiles removes ~/.tx9/boxes/<name>.env files with no
110+// corresponding box left in the daemon — leftovers from a box deleted by
111+// some other means (e.g. `docker rm` directly) that skipped tx9's own
112+// cleanup.
113+func pruneStateFiles(ctx context.Context, cli *docker.Client) ([]string, error) {
114+ boxes, err := box.List(ctx, cli)
115+ if err != nil {
116+ return nil, err
117+ }
118+ known := make(map[string]bool, len(boxes))
119+ for _, b := range boxes {
120+ known[b.Name] = true
121+ }
122+
123+ dir, err := state.BoxesDir()
124+ if err != nil {
125+ return nil, err
126+ }
127+ entries, err := os.ReadDir(dir)
128+ if err != nil {
129+ return nil, fmt.Errorf("read %s: %w", dir, err)
130+ }
131+
132+ var removed []string
133+ for _, e := range entries {
134+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".env") {
135+ continue
136+ }
137+ name := strings.TrimSuffix(e.Name(), ".env")
138+ if known[name] {
139+ continue
140+ }
141+
142+ // An env file is written by create/import before either of them
143+ // takes this box's per-box lock and holds it for their entire
144+ // run, so a box with no containers yet (known[name] == false)
145+ // might just be mid-create/mid-import rather than truly
146+ // orphaned. Try the same non-blocking lock they use; if it's
147+ // held, skip this file instead of deleting state out from under
148+ // a running operation.
149+ lockPath, err := state.LockPath(name)
150+ if err != nil {
151+ return removed, err
152+ }
153+ release, err := lock.Acquire(lockPath)
154+ if err != nil {
155+ fmt.Printf("tx9: skipping %s: operation in progress\n", name)
156+ continue
157+ }
158+
159+ path := filepath.Join(dir, e.Name())
160+ rmErr := os.Remove(path)
161+ release()
162+ if rmErr != nil {
163+ return removed, fmt.Errorf("remove %s: %w", path, rmErr)
164+ }
165+ removed = append(removed, path)
166+ }
167+ return removed, nil
168+}
internal/cli/cmd_startstop.go+60−0
@@ -0,0 +1,60 @@
1+package cli
2+
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+
8+ "github.com/davis7dotsh/tx9/internal/box"
9+ "github.com/davis7dotsh/tx9/internal/docker"
10+)
11+
12+// cmdStart implements `tx9 start <box>`: start both containers together.
13+// Volumes persist.
14+func cmdStart(args []string) error {
15+ fs := flag.NewFlagSet("start", flag.ContinueOnError)
16+ if err := parseFlagsAnywhere(fs, args); err != nil {
17+ return err
18+ }
19+ name, err := requireBoxName(fs, "start")
20+ if err != nil {
21+ return err
22+ }
23+
24+ return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error {
25+ b, err := box.Get(ctx, cli, name)
26+ if err != nil {
27+ return fmt.Errorf("start %s: %w", name, err)
28+ }
29+ if err := box.Start(ctx, cli, b); err != nil {
30+ return fmt.Errorf("start %s: %w", name, err)
31+ }
32+ fmt.Printf("tx9: box %s started\n", name)
33+ return nil
34+ })
35+}
36+
37+// cmdStop implements `tx9 stop <box>`: stop both containers together.
38+// Volumes persist.
39+func cmdStop(args []string) error {
40+ fs := flag.NewFlagSet("stop", flag.ContinueOnError)
41+ if err := parseFlagsAnywhere(fs, args); err != nil {
42+ return err
43+ }
44+ name, err := requireBoxName(fs, "stop")
45+ if err != nil {
46+ return err
47+ }
48+
49+ return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error {
50+ b, err := box.Get(ctx, cli, name)
51+ if err != nil {
52+ return fmt.Errorf("stop %s: %w", name, err)
53+ }
54+ if err := box.Stop(ctx, cli, b); err != nil {
55+ return fmt.Errorf("stop %s: %w", name, err)
56+ }
57+ fmt.Printf("tx9: box %s stopped\n", name)
58+ return nil
59+ })
60+}
internal/cli/cmd_upgrade.go+137−0
@@ -0,0 +1,137 @@
1+package cli
2+
3+import (
4+ "context"
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "os"
9+
10+ "github.com/davis7dotsh/tx9/internal/box"
11+ "github.com/davis7dotsh/tx9/internal/docker"
12+ "github.com/davis7dotsh/tx9/internal/selfupdate"
13+ "github.com/davis7dotsh/tx9/internal/version"
14+)
15+
16+// cmdUpgrade implements `tx9 upgrade [box]`.
17+//
18+// With a box argument: stop and remove both of the box's containers
19+// (network, volumes, and the minted token are left untouched), make sure
20+// the current CLI's image is built, then recreate both containers on that
21+// image via box.Create (which is idempotent against the pre-existing
22+// network/volumes) and run the same readiness gate `create` uses.
23+//
24+// With no argument: self-update the tx9 binary itself (internal/selfupdate,
25+// tx9-cli-design.md decision 11) — query GitHub Releases, download +
26+// checksum-verify the matching asset, and atomically replace the running
27+// executable.
28+func cmdUpgrade(args []string) error {
29+ fs := flag.NewFlagSet("upgrade", flag.ContinueOnError)
30+ force := fs.Bool("force", false, "self-update: skip the dev-build guard and reinstall even if already current")
31+ if err := parseFlagsAnywhere(fs, args); err != nil {
32+ return err
33+ }
34+
35+ if fs.NArg() == 0 {
36+ return cmdUpgradeSelf(*force)
37+ }
38+ name := fs.Arg(0)
39+
40+ return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error {
41+ b, err := box.Get(ctx, cli, name)
42+ if err != nil {
43+ return fmt.Errorf("upgrade %s: %w", name, err)
44+ }
45+ fromVersion := b.Version
46+
47+ imageTag := fmt.Sprintf("tx9-box:%s", version.Version)
48+ if err := ensureBoxImage(ctx, cli, imageTag); err != nil {
49+ return fmt.Errorf("upgrade %s: %w", name, err)
50+ }
51+
52+ tok, err := box.Token(name)
53+ if err != nil {
54+ return fmt.Errorf("upgrade %s: %w", name, err)
55+ }
56+
57+ fmt.Printf("tx9: stopping and removing %s's containers (network/volumes/token kept)\n", name)
58+ if err := removeBoxContainers(ctx, cli, b); err != nil {
59+ return fmt.Errorf("upgrade %s: %w", name, err)
60+ }
61+
62+ fmt.Printf("tx9: recreating %s on %s\n", name, imageTag)
63+ newB, err := box.Create(ctx, cli, box.CreateOpts{Name: name, Image: imageTag, Token: tok})
64+ if err != nil {
65+ return fmt.Errorf("upgrade %s: containers not recreated, box left without them: %w", name, err)
66+ }
67+
68+ fmt.Println("tx9: preparing runtime (executor reachability, MCP wiring, doctor)")
69+ if err := box.PrepareRuntime(ctx, cli, newB, tok, os.Stdout); err != nil {
70+ return fmt.Errorf("upgrade %s: runtime failed: %w", name, err)
71+ }
72+
73+ if fromVersion == version.Version {
74+ fmt.Printf("tx9: box %s recreated (already on %s; containers reset)\n", name, version.Version)
75+ } else {
76+ fmt.Printf("tx9: box %s upgraded %s -> %s\n", name, displayVersion(fromVersion), version.Version)
77+ }
78+ return nil
79+ })
80+}
81+
82+// cmdUpgradeSelf implements `tx9 upgrade` with no box argument: self-update
83+// the running binary via internal/selfupdate. ErrNoRelease and
84+// ErrDevVersion get their own actionable messages since they're the
85+// expected/common cases (no releases published yet; a `go run`/unversioned
86+// build); anything else from Update is returned as-is, already wrapped
87+// with context by that package.
88+func cmdUpgradeSelf(force bool) error {
89+ res, err := selfupdate.Update(selfupdate.Options{
90+ CurrentVersion: version.Version,
91+ Force: force,
92+ Out: os.Stdout,
93+ })
94+ if err != nil {
95+ if errors.Is(err, selfupdate.ErrNoRelease) {
96+ return fmt.Errorf("upgrade: no releases published yet for %s (build from source, or check back after the first release)", selfupdate.Repo)
97+ }
98+ // ErrDevVersion and everything else already read fine wrapped
99+ // plainly; selfupdate's own errors carry their own context.
100+ return fmt.Errorf("upgrade: %w", err)
101+ }
102+
103+ if !res.Applied {
104+ fmt.Printf("tx9: already up to date (%s)\n", displayVersion(version.Version))
105+ return nil
106+ }
107+ fmt.Printf("tx9: upgraded %s -> %s\n", displayVersion(version.Version), res.ToVersion)
108+ return nil
109+}
110+
111+// removeBoxContainers stops (best-effort) then force-removes a box's
112+// agent/executor containers, leaving its network, volumes, and token file
113+// alone — the part of box.Destroy this needs, without the rest of the
114+// teardown.
115+func removeBoxContainers(ctx context.Context, cli *docker.Client, b *box.Box) error {
116+ var errs []error
117+ if b.AgentID != "" {
118+ _ = cli.ContainerStop(ctx, b.AgentID, nil)
119+ if err := cli.ContainerRemove(ctx, b.AgentID, true); err != nil {
120+ errs = append(errs, fmt.Errorf("remove agent: %w", err))
121+ }
122+ }
123+ if b.ExecutorID != "" {
124+ _ = cli.ContainerStop(ctx, b.ExecutorID, nil)
125+ if err := cli.ContainerRemove(ctx, b.ExecutorID, true); err != nil {
126+ errs = append(errs, fmt.Errorf("remove executor: %w", err))
127+ }
128+ }
129+ return errors.Join(errs...)
130+}
131+
132+func displayVersion(v string) string {
133+ if v == "" {
134+ return "?"
135+ }
136+ return v
137+}
internal/cli/dispatch.go+137−0
@@ -0,0 +1,137 @@
1+// Package cli is tx9's command dispatcher: it parses the subcommand off
2+// os.Args and routes to the per-command implementation in its own
3+// cmd_*.go file. Every command is registered here so later work on
4+// individual commands never needs to touch this file.
5+package cli
6+
7+import (
8+ "fmt"
9+ "io/fs"
10+ "os"
11+
12+ "github.com/davis7dotsh/tx9/internal/version"
13+)
14+
15+// BuildContext is the embedded Docker build-context filesystem (provision/,
16+// guest/, docker/, box.env). It is set by Run from the root-level embed.FS
17+// in /assets.go — package main can't be imported by internal packages (see
18+// that file's doc comment for why), so it's threaded through here instead.
19+// Commands that build or rebuild the box image (create, upgrade) read it.
20+var BuildContext fs.FS
21+
22+// commandFunc is the signature every subcommand implements. Each owns its
23+// own flag.FlagSet; args excludes the program name and the subcommand
24+// itself (i.e. it's exactly what flag.FlagSet.Parse expects).
25+type commandFunc func(args []string) error
26+
27+// commands maps canonical subcommand names to their implementation.
28+// Registered in registerCommands below.
29+var commands = map[string]commandFunc{}
30+
31+// commandOrder lists canonical subcommand names in the order they should
32+// appear in help output (matches the command surface table in
33+// docs/tx9-cli-design.md).
34+var commandOrder = []string{
35+ "create", "list", "enter", "start", "stop", "backup", "import",
36+ "open", "doctor", "upgrade", "delete", "prune",
37+}
38+
39+// commandHelp is a one-line description per canonical command, for help
40+// output (matches docs/tx9-cli-design.md's command surface table).
41+var commandHelp = map[string]string{
42+ "create": "generate/build if needed, create and start a new box",
43+ "list": "list boxes on this machine (state, image version, dashboard URL)",
44+ "enter": "exec into a box's agent container (alias: ssh)",
45+ "start": "start a stopped box",
46+ "stop": "stop a running box",
47+ "backup": "archive a box to a .tx9 file (alias: export)",
48+ "import": "restore a box from a .tx9 file",
49+ "open": "print/open a box's authenticated dashboard URL",
50+ "doctor": "run health checks against a box",
51+ "upgrade": "self-update the CLI, or move a box onto the current image",
52+ "delete": "delete a box's containers, volumes, and state",
53+ "prune": "remove unused tx9-box images and stale state",
54+}
55+
56+// aliases maps alternate spellings to their canonical command name
57+// (decision 4: enter/ssh and backup/export are aliases).
58+var aliases = map[string]string{
59+ "ssh": "enter",
60+ "export": "backup",
61+}
62+
63+func registerCommands() {
64+ commands["create"] = cmdCreate
65+ commands["list"] = cmdList
66+ commands["enter"] = cmdEnter
67+ commands["start"] = cmdStart
68+ commands["stop"] = cmdStop
69+ commands["backup"] = cmdBackup
70+ commands["import"] = cmdImport
71+ commands["open"] = cmdOpen
72+ commands["doctor"] = cmdDoctor
73+ commands["upgrade"] = cmdUpgrade
74+ commands["delete"] = cmdDelete
75+ commands["prune"] = cmdPrune
76+}
77+
78+func init() {
79+ registerCommands()
80+}
81+
82+// Run parses os.Args-style arguments (args[0] is the program name),
83+// dispatches to the matching subcommand, and returns a process exit code.
84+// buildContext is the embedded build-context filesystem from /assets.go.
85+func Run(args []string, buildContext fs.FS) int {
86+ BuildContext = buildContext
87+
88+ if len(args) < 2 {
89+ printUsage(os.Stdout)
90+ return 1
91+ }
92+
93+ sub := args[1]
94+ rest := args[2:]
95+
96+ switch sub {
97+ case "-h", "-help", "--help", "help":
98+ printUsage(os.Stdout)
99+ return 0
100+ case "-v", "-version", "--version", "version":
101+ fmt.Println(version.Version)
102+ return 0
103+ }
104+
105+ if canonical, ok := aliases[sub]; ok {
106+ sub = canonical
107+ }
108+
109+ fn, ok := commands[sub]
110+ if !ok {
111+ fmt.Fprintf(os.Stderr, "tx9: unknown command %q\n\n", sub)
112+ printUsage(os.Stderr)
113+ return 1
114+ }
115+
116+ if err := fn(rest); err != nil {
117+ fmt.Fprintf(os.Stderr, "tx9: %v\n", err)
118+ return 1
119+ }
120+ return 0
121+}
122+
123+func printUsage(w *os.File) {
124+ fmt.Fprintf(w, "tx9 %s — manage hermes boxes\n\n", version.Version)
125+ fmt.Fprintln(w, "Usage: tx9 <command> [args]")
126+ fmt.Fprintln(w, "\nCommands:")
127+ for _, name := range commandOrder {
128+ fmt.Fprintf(w, " %-10s %s\n", name, commandHelp[name])
129+ }
130+ fmt.Fprintln(w, "\nAliases: ssh -> enter, export -> backup")
131+ fmt.Fprintln(w, "\nOther:")
132+ fmt.Fprintln(w, " help show this message")
133+ fmt.Fprintln(w, " version print the CLI version")
134+ fmt.Fprintln(w, "\nFlags:")
135+ fmt.Fprintln(w, " -h, --help show this message")
136+ fmt.Fprintln(w, " -v, --version print the CLI version")
137+}
internal/docker/client.go+317−0
@@ -0,0 +1,317 @@
1+// Package docker is a thin wrapper over the official Docker Engine Go SDK
2+// (decision 8, tx9-cli-design.md). It exposes only what box lifecycle
3+// commands need — images, containers, networks, volumes, exec — behind a
4+// small struct so a remote daemon (SSH tunnel / DOCKER_HOST) can slot in
5+// later without restructuring callers.
6+package docker
7+
8+import (
9+ "bytes"
10+ "context"
11+ "fmt"
12+ "io"
13+
14+ "github.com/docker/docker/api/types/build"
15+ "github.com/docker/docker/api/types/container"
16+ "github.com/docker/docker/api/types/filters"
17+ "github.com/docker/docker/api/types/image"
18+ "github.com/docker/docker/api/types/network"
19+ "github.com/docker/docker/api/types/volume"
20+ "github.com/docker/docker/client"
21+ "github.com/docker/docker/pkg/jsonmessage"
22+ "github.com/docker/docker/pkg/stdcopy"
23+ "github.com/docker/go-connections/nat"
24+
25+ "github.com/davis7dotsh/tx9/internal/assets"
26+)
27+
28+// Client wraps the Docker Engine API client with tx9-shaped helpers.
29+type Client struct {
30+ cli *client.Client
31+}
32+
33+// NewClient connects to the Docker daemon from the environment
34+// (DOCKER_HOST/DOCKER_CERT_PATH/etc, same as the docker CLI) and negotiates
35+// the API version so it works across a range of daemon versions.
36+func NewClient(ctx context.Context) (*Client, error) {
37+ cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
38+ if err != nil {
39+ return nil, fmt.Errorf("docker: new client: %w", err)
40+ }
41+ if _, err := cli.Ping(ctx); err != nil {
42+ cli.Close()
43+ return nil, fmt.Errorf("docker: daemon unreachable: %w", err)
44+ }
45+ return &Client{cli: cli}, nil
46+}
47+
48+// Close releases the underlying HTTP client's connections.
49+func (c *Client) Close() error {
50+ return c.cli.Close()
51+}
52+
53+// Raw exposes the underlying SDK client for calls this wrapper doesn't
54+// (yet) cover.
55+func (c *Client) Raw() *client.Client {
56+ return c.cli
57+}
58+
59+// ImageExists reports whether tag is present in the local image store.
60+func (c *Client) ImageExists(ctx context.Context, tag string) (bool, error) {
61+ _, err := c.cli.ImageInspect(ctx, tag)
62+ if err == nil {
63+ return true, nil
64+ }
65+ if client.IsErrNotFound(err) {
66+ return false, nil
67+ }
68+ return false, fmt.Errorf("docker: image inspect %s: %w", tag, err)
69+}
70+
71+// BuildImage builds tag from buildContext (a tar stream — see
72+// internal/assets.BuildContextTar), streaming daemon build progress as
73+// human-readable lines to progress.
74+func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, tag string, progress io.Writer) error {
75+ resp, err := c.cli.ImageBuild(ctx, buildContext, build.ImageBuildOptions{
76+ Tags: []string{tag},
77+ Dockerfile: assets.DockerfilePath,
78+ Remove: true,
79+ })
80+ if err != nil {
81+ return fmt.Errorf("docker: image build %s: %w", tag, err)
82+ }
83+ defer resp.Body.Close()
84+
85+ if err := jsonmessage.DisplayJSONMessagesStream(resp.Body, progress, 0, false, nil); err != nil {
86+ return fmt.Errorf("docker: image build %s: %w", tag, err)
87+ }
88+ return nil
89+}
90+
91+// ListBoxContainers returns every container tx9 has ever labeled
92+// (LabelManaged), running or not, so `tx9 list` can reconstruct state from
93+// the daemon alone.
94+func (c *Client) ListBoxContainers(ctx context.Context) ([]container.Summary, error) {
95+ f := filters.NewArgs(filters.Arg("label", LabelManaged+"=1"))
96+ containers, err := c.cli.ContainerList(ctx, container.ListOptions{All: true, Filters: f})
97+ if err != nil {
98+ return nil, fmt.Errorf("docker: list containers: %w", err)
99+ }
100+ return containers, nil
101+}
102+
103+// NetworkCreate creates a private bridge network for a box, with IPv6
104+// explicitly disabled at the network level (dossier §1.4 — compose's
105+// default when no IPv6 config is requested; tx9 replicates it explicitly).
106+func (c *Client) NetworkCreate(ctx context.Context, name string, labels map[string]string) (string, error) {
107+ enableIPv6 := false
108+ resp, err := c.cli.NetworkCreate(ctx, name, network.CreateOptions{
109+ Driver: "bridge",
110+ EnableIPv6: &enableIPv6,
111+ Labels: labels,
112+ })
113+ if err != nil {
114+ return "", fmt.Errorf("docker: network create %s: %w", name, err)
115+ }
116+ return resp.ID, nil
117+}
118+
119+// NetworkRemove removes a network by ID or name.
120+func (c *Client) NetworkRemove(ctx context.Context, id string) error {
121+ if err := c.cli.NetworkRemove(ctx, id); err != nil {
122+ return fmt.Errorf("docker: network remove %s: %w", id, err)
123+ }
124+ return nil
125+}
126+
127+// VolumeCreate creates a named volume.
128+func (c *Client) VolumeCreate(ctx context.Context, name string, labels map[string]string) (string, error) {
129+ v, err := c.cli.VolumeCreate(ctx, volume.CreateOptions{Name: name, Labels: labels})
130+ if err != nil {
131+ return "", fmt.Errorf("docker: volume create %s: %w", name, err)
132+ }
133+ return v.Name, nil
134+}
135+
136+// VolumeRemove removes a named volume.
137+func (c *Client) VolumeRemove(ctx context.Context, name string, force bool) error {
138+ if err := c.cli.VolumeRemove(ctx, name, force); err != nil {
139+ return fmt.Errorf("docker: volume remove %s: %w", name, err)
140+ }
141+ return nil
142+}
143+
144+// ContainerSpec describes a container tx9 wants created. It only covers the
145+// fields the box topology (dossier §1) actually uses — not the full Engine
146+// API surface.
147+type ContainerSpec struct {
148+ Name string
149+ Image string
150+ Entrypoint []string
151+ Env []string
152+ Labels map[string]string
153+ Hostname string
154+ NetworkID string
155+ NetworkAliases []string
156+ Binds []string // "volume:/container/path" or "volume:/path:ro"
157+ ExposedPorts nat.PortSet
158+ PortBindings nat.PortMap
159+ Sysctls map[string]string
160+ NanoCPUs int64
161+ MemoryBytes int64
162+}
163+
164+// ContainerCreate creates (but does not start) a container per spec, with
165+// `--init` and `restart: unless-stopped` baked in (dossier §1.3 — every box
166+// container uses both).
167+func (c *Client) ContainerCreate(ctx context.Context, spec ContainerSpec) (string, error) {
168+ initEnabled := true
169+ cfg := &container.Config{
170+ Image: spec.Image,
171+ Entrypoint: spec.Entrypoint,
172+ Env: spec.Env,
173+ Labels: spec.Labels,
174+ Hostname: spec.Hostname,
175+ ExposedPorts: spec.ExposedPorts,
176+ }
177+ hostCfg := &container.HostConfig{
178+ Binds: spec.Binds,
179+ PortBindings: spec.PortBindings,
180+ Sysctls: spec.Sysctls,
181+ Init: &initEnabled,
182+ RestartPolicy: container.RestartPolicy{
183+ Name: container.RestartPolicyUnlessStopped,
184+ },
185+ Resources: container.Resources{
186+ NanoCPUs: spec.NanoCPUs,
187+ Memory: spec.MemoryBytes,
188+ },
189+ }
190+
191+ var netCfg *network.NetworkingConfig
192+ if spec.NetworkID != "" {
193+ netCfg = &network.NetworkingConfig{
194+ EndpointsConfig: map[string]*network.EndpointSettings{
195+ spec.NetworkID: {Aliases: spec.NetworkAliases},
196+ },
197+ }
198+ }
199+
200+ resp, err := c.cli.ContainerCreate(ctx, cfg, hostCfg, netCfg, nil, spec.Name)
201+ if err != nil {
202+ return "", fmt.Errorf("docker: container create %s: %w", spec.Name, err)
203+ }
204+ return resp.ID, nil
205+}
206+
207+// ContainerStart starts a previously-created container.
208+func (c *Client) ContainerStart(ctx context.Context, id string) error {
209+ if err := c.cli.ContainerStart(ctx, id, container.StartOptions{}); err != nil {
210+ return fmt.Errorf("docker: container start %s: %w", id, err)
211+ }
212+ return nil
213+}
214+
215+// ContainerStop stops a running container (nil timeout uses the daemon
216+// default grace period).
217+func (c *Client) ContainerStop(ctx context.Context, id string, timeoutSeconds *int) error {
218+ if err := c.cli.ContainerStop(ctx, id, container.StopOptions{Timeout: timeoutSeconds}); err != nil {
219+ return fmt.Errorf("docker: container stop %s: %w", id, err)
220+ }
221+ return nil
222+}
223+
224+// ContainerRemove removes a container. force also kills it if running.
225+func (c *Client) ContainerRemove(ctx context.Context, id string, force bool) error {
226+ if err := c.cli.ContainerRemove(ctx, id, container.RemoveOptions{Force: force, RemoveVolumes: false}); err != nil {
227+ return fmt.Errorf("docker: container remove %s: %w", id, err)
228+ }
229+ return nil
230+}
231+
232+// ContainerInspect returns full container state.
233+func (c *Client) ContainerInspect(ctx context.Context, id string) (container.InspectResponse, error) {
234+ resp, err := c.cli.ContainerInspect(ctx, id)
235+ if err != nil {
236+ return container.InspectResponse{}, fmt.Errorf("docker: container inspect %s: %w", id, err)
237+ }
238+ return resp, nil
239+}
240+
241+// ImageInspect returns full image metadata (used by `list` to show
242+// per-box image version drift).
243+func (c *Client) ImageInspect(ctx context.Context, tag string) (image.InspectResponse, error) {
244+ resp, err := c.cli.ImageInspect(ctx, tag)
245+ if err != nil {
246+ return image.InspectResponse{}, fmt.Errorf("docker: image inspect %s: %w", tag, err)
247+ }
248+ return resp, nil
249+}
250+
251+// ImageList returns image summaries matching a reference filter (e.g.
252+// "tx9-box:*"), used by `tx9 prune` to find stale box images.
253+func (c *Client) ImageList(ctx context.Context, referenceFilter string) ([]image.Summary, error) {
254+ f := filters.NewArgs(filters.Arg("reference", referenceFilter))
255+ images, err := c.cli.ImageList(ctx, image.ListOptions{Filters: f})
256+ if err != nil {
257+ return nil, fmt.Errorf("docker: list images %s: %w", referenceFilter, err)
258+ }
259+ return images, nil
260+}
261+
262+// ImageRemove removes an image by ID or tag.
263+func (c *Client) ImageRemove(ctx context.Context, ref string) error {
264+ if _, err := c.cli.ImageRemove(ctx, ref, image.RemoveOptions{}); err != nil {
265+ return fmt.Errorf("docker: image remove %s: %w", ref, err)
266+ }
267+ return nil
268+}
269+
270+// ExecResult is the outcome of a non-interactive Exec call.
271+type ExecResult struct {
272+ Stdout string
273+ Stderr string
274+ ExitCode int
275+}
276+
277+// Exec runs cmd inside containerID (no TTY), waits for completion, and
278+// returns its combined stdout/stderr and exit code. This is the
279+// "remote-control" pattern used to invoke `hb` inside the agent container
280+// (dossier §3, `_guest_hb`) for calls like `up`/`doctor`/`wire-once`.
281+//
282+// Interactive/TTY exec (e.g. `tx9 enter`) needs raw terminal handling this
283+// helper doesn't provide and should use Raw() directly.
284+func (c *Client) Exec(ctx context.Context, containerID string, cmd []string, env []string, user string) (ExecResult, error) {
285+ created, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
286+ Cmd: cmd,
287+ Env: env,
288+ User: user,
289+ AttachStdout: true,
290+ AttachStderr: true,
291+ })
292+ if err != nil {
293+ return ExecResult{}, fmt.Errorf("docker: exec create: %w", err)
294+ }
295+
296+ attach, err := c.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})
297+ if err != nil {
298+ return ExecResult{}, fmt.Errorf("docker: exec attach: %w", err)
299+ }
300+ defer attach.Close()
301+
302+ var stdout, stderr bytes.Buffer
303+ if _, err := stdcopy.StdCopy(&stdout, &stderr, attach.Reader); err != nil {
304+ return ExecResult{}, fmt.Errorf("docker: exec read output: %w", err)
305+ }
306+
307+ inspect, err := c.cli.ContainerExecInspect(ctx, created.ID)
308+ if err != nil {
309+ return ExecResult{}, fmt.Errorf("docker: exec inspect: %w", err)
310+ }
311+
312+ return ExecResult{
313+ Stdout: stdout.String(),
314+ Stderr: stderr.String(),
315+ ExitCode: inspect.ExitCode,
316+ }, nil
317+}
internal/docker/ephemeral.go+137−0
@@ -0,0 +1,137 @@
1+package docker
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "sync"
8+
9+ "github.com/docker/docker/api/types/container"
10+ "github.com/docker/docker/pkg/stdcopy"
11+)
12+
13+// ExecStream runs cmd inside the already-running container containerID,
14+// like Exec, but streams stdout/stderr directly to the caller's writers
15+// instead of buffering them in memory. This is what `tx9 backup` uses to
16+// pull a multi-gigabyte tar stream out of the agent container's /data
17+// volume without holding the whole archive in RAM.
18+func (c *Client) ExecStream(ctx context.Context, containerID string, cmd []string, env []string, user string, stdout, stderr io.Writer) (int, error) {
19+ created, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
20+ Cmd: cmd,
21+ Env: env,
22+ User: user,
23+ AttachStdout: true,
24+ AttachStderr: true,
25+ })
26+ if err != nil {
27+ return 0, fmt.Errorf("docker: exec create: %w", err)
28+ }
29+
30+ attach, err := c.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})
31+ if err != nil {
32+ return 0, fmt.Errorf("docker: exec attach: %w", err)
33+ }
34+ defer attach.Close()
35+
36+ if _, err := stdcopy.StdCopy(stdout, stderr, attach.Reader); err != nil {
37+ return 0, fmt.Errorf("docker: exec read output: %w", err)
38+ }
39+
40+ inspect, err := c.cli.ContainerExecInspect(ctx, created.ID)
41+ if err != nil {
42+ return 0, fmt.Errorf("docker: exec inspect: %w", err)
43+ }
44+ return inspect.ExitCode, nil
45+}
46+
47+// EphemeralOpts parameterizes RunEphemeral.
48+type EphemeralOpts struct {
49+ Image string
50+ Entrypoint []string
51+ Cmd []string
52+ Env []string
53+ Binds []string // "volume:/container/path"
54+ Stdin io.Reader // optional; nil means no stdin is attached
55+ Stdout io.Writer
56+ Stderr io.Writer
57+}
58+
59+// RunEphemeral creates, starts, waits for, and always removes a throwaway
60+// container from opts.Image with opts.Binds mounted — the "stage-then-
61+// promote" pattern `tx9 import` uses to write into a box's agent-data
62+// volume before the agent container itself has ever started (dossier
63+// §7.2's `docker run --rm -i -v ... --entrypoint bash "$IMAGE" -c '...'`).
64+// It returns the container's exit code; a non-nil error means the
65+// container could not be run at all (create/attach/start/wait failure),
66+// not merely that the command inside it exited non-zero.
67+func (c *Client) RunEphemeral(ctx context.Context, opts EphemeralOpts) (int, error) {
68+ hasStdin := opts.Stdin != nil
69+ cfg := &container.Config{
70+ Image: opts.Image,
71+ Entrypoint: opts.Entrypoint,
72+ Cmd: opts.Cmd,
73+ Env: opts.Env,
74+ AttachStdin: hasStdin,
75+ AttachStdout: true,
76+ AttachStderr: true,
77+ OpenStdin: hasStdin,
78+ StdinOnce: hasStdin,
79+ }
80+ hostCfg := &container.HostConfig{Binds: opts.Binds}
81+
82+ resp, err := c.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, "")
83+ if err != nil {
84+ return 0, fmt.Errorf("docker: ephemeral create: %w", err)
85+ }
86+ defer c.cli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
87+
88+ attach, err := c.cli.ContainerAttach(ctx, resp.ID, container.AttachOptions{
89+ Stream: true,
90+ Stdin: hasStdin,
91+ Stdout: true,
92+ Stderr: true,
93+ })
94+ if err != nil {
95+ return 0, fmt.Errorf("docker: ephemeral attach: %w", err)
96+ }
97+ defer attach.Close()
98+
99+ // Reserve "not running" wait BEFORE start so we can't miss a fast exit.
100+ statusCh, waitErrCh := c.cli.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
101+
102+ if err := c.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
103+ return 0, fmt.Errorf("docker: ephemeral start: %w", err)
104+ }
105+
106+ var stdinWG sync.WaitGroup
107+ if hasStdin {
108+ stdinWG.Add(1)
109+ go func() {
110+ defer stdinWG.Done()
111+ io.Copy(attach.Conn, opts.Stdin)
112+ attach.CloseWrite()
113+ }()
114+ }
115+
116+ copyDone := make(chan error, 1)
117+ go func() {
118+ _, cerr := stdcopy.StdCopy(opts.Stdout, opts.Stderr, attach.Reader)
119+ copyDone <- cerr
120+ }()
121+
122+ var exitCode int
123+ select {
124+ case werr := <-waitErrCh:
125+ return 0, fmt.Errorf("docker: ephemeral wait: %w", werr)
126+ case status := <-statusCh:
127+ exitCode = int(status.StatusCode)
128+ }
129+
130+ if hasStdin {
131+ stdinWG.Wait()
132+ }
133+ if cerr := <-copyDone; cerr != nil {
134+ return exitCode, fmt.Errorf("docker: ephemeral read output: %w", cerr)
135+ }
136+ return exitCode, nil
137+}
internal/docker/labels.go+31−0
@@ -0,0 +1,31 @@
1+package docker
2+
3+// Label keys tx9 stamps on every Docker object it creates, so `tx9 list`
4+// can reconstruct truth from the daemon even if ~/.tx9 is lost (decision 2).
5+const (
6+ LabelManaged = "tx9" // "1" on every tx9-managed object
7+ LabelBox = "tx9.box" // box name
8+ LabelVersion = "tx9.version" // image/CLI version that created the object
9+ LabelRole = "tx9.role" // "agent" or "executor"
10+)
11+
12+// Roles for LabelRole.
13+const (
14+ RoleAgent = "agent"
15+ RoleExecutor = "executor"
16+)
17+
18+// BoxLabels returns the standard label set for an object belonging to box
19+// name, tagged with version, playing role (RoleAgent/RoleExecutor, or ""
20+// for objects — like the per-box network — that aren't container-specific).
21+func BoxLabels(name, version, role string) map[string]string {
22+ labels := map[string]string{
23+ LabelManaged: "1",
24+ LabelBox: name,
25+ LabelVersion: version,
26+ }
27+ if role != "" {
28+ labels[LabelRole] = role
29+ }
30+ return labels
31+}
internal/lock/lock.go+42−0
@@ -0,0 +1,42 @@
1+// Package lock provides per-box, non-blocking flock-based mutual exclusion,
2+// mirroring boxd's `_lock()` semantics: fail fast, never queue (dossier §8.1).
3+package lock
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+// Acquire takes an exclusive, non-blocking flock on path (creating it if
15+// needed) and returns a release func. If another process already holds the
16+// lock, it returns an error immediately rather than blocking.
17+func Acquire(path string) (release func(), err error) {
18+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
19+ if err != nil {
20+ return nil, fmt.Errorf("lock: acquire: %w", err)
21+ }
22+ if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
23+ f.Close()
24+ if err == unix.EWOULDBLOCK {
25+ return nil, fmt.Errorf("lock: another tx9 operation is running on box %s", boxNameFromPath(path))
26+ }
27+ return nil, fmt.Errorf("lock: acquire: %w", err)
28+ }
29+ released := false
30+ return func() {
31+ if released {
32+ return
33+ }
34+ released = true
35+ unix.Flock(int(f.Fd()), unix.LOCK_UN)
36+ f.Close()
37+ }, nil
38+}
39+
40+func boxNameFromPath(path string) string {
41+ return strings.TrimSuffix(filepath.Base(path), ".lock")
42+}
internal/names/names.go+103−0
@@ -0,0 +1,103 @@
1+// Package names generates and validates friendly box names (e.g. "large-cat").
2+package names
3+
4+import (
5+ "fmt"
6+ "math/rand/v2"
7+ "regexp"
8+)
9+
10+var adjectives = []string{
11+ "large", "small", "quick", "slow", "quiet", "loud", "brave", "calm",
12+ "eager", "gentle", "happy", "jolly", "kind", "lively", "mighty", "noble",
13+ "proud", "silly", "witty", "young", "amber", "azure", "bold", "bright",
14+ "clever", "crisp", "dapper", "fuzzy", "grumpy", "handy", "icy", "keen",
15+ "lucky", "merry", "nifty", "plucky", "rowdy", "sturdy", "tidy", "vivid",
16+}
17+
18+var animals = []string{
19+ "cat", "dog", "fox", "owl", "wolf", "bear", "hawk", "seal", "crane",
20+ "otter", "lynx", "moose", "heron", "raven", "gecko", "ibis", "koala",
21+ "mole", "newt", "puma", "quail", "ram", "shark", "toad", "vole",
22+ "whale", "yak", "zebra", "bison", "civet", "eagle", "ferret", "goose",
23+ "hare", "jay", "kite", "loris", "mink", "panda", "stoat",
24+}
25+
26+// reserved names must never be generated or accepted, to avoid colliding
27+// with CLI vocabulary or Docker/filesystem-sensitive words.
28+var reserved = map[string]bool{
29+ "create": true, "list": true, "enter": true, "ssh": true,
30+ "start": true, "stop": true, "backup": true, "export": true,
31+ "import": true, "open": true, "doctor": true, "upgrade": true,
32+ "delete": true, "prune": true, "help": true, "version": true,
33+ "all": true, "none": true, "default": true,
34+}
35+
36+var validPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
37+
38+const maxNameLen = 32
39+
40+// Generate returns a friendly "adjective-animal" name not present in taken,
41+// picked randomly rather than always starting from the same fixed point in
42+// the word-list product (which used to mean the first box ever created
43+// always got the same name). No cryptographic randomness is needed here,
44+// so math/rand/v2 is fine.
45+//
46+// It tries a bounded number of random (adjective, animal) picks first; if
47+// that's unlucky (the product space is nearly exhausted), it falls back to
48+// the exhaustive scan that used to be the only strategy; if even the whole
49+// product space turns out to be taken, it disambiguates with a random
50+// numeric suffix.
51+func Generate(taken map[string]bool) (string, error) {
52+ maxAttempts := 10 * len(adjectives) * len(animals)
53+ for attempt := 0; attempt < maxAttempts; attempt++ {
54+ adj := adjectives[rand.IntN(len(adjectives))]
55+ an := animals[rand.IntN(len(animals))]
56+ name := adj + "-" + an
57+ if !taken[name] {
58+ return name, nil
59+ }
60+ }
61+
62+ // Random attempts didn't land on a free name — fall back to an
63+ // exhaustive scan so a merely-crowded (but not full) namespace still
64+ // resolves deterministically instead of relying on luck.
65+ for _, adj := range adjectives {
66+ for _, an := range animals {
67+ name := adj + "-" + an
68+ if !taken[name] {
69+ return name, nil
70+ }
71+ }
72+ }
73+
74+ // Word-list space exhausted (very unlikely) — disambiguate with a
75+ // random 4-digit suffix on a random base name.
76+ for attempt := 0; attempt < 100; attempt++ {
77+ adj := adjectives[rand.IntN(len(adjectives))]
78+ an := animals[rand.IntN(len(animals))]
79+ name := fmt.Sprintf("%s-%s-%04d", adj, an, rand.IntN(10000))
80+ if !taken[name] {
81+ return name, nil
82+ }
83+ }
84+ return "", fmt.Errorf("names: generate: exhausted candidates")
85+}
86+
87+// Validate reports whether name is an acceptable box name: lowercase
88+// [a-z0-9_-], starting with an alphanumeric, max 32 chars, not reserved.
89+func Validate(name string) error {
90+ if name == "" {
91+ return fmt.Errorf("names: validate: name must not be empty")
92+ }
93+ if len(name) > maxNameLen {
94+ return fmt.Errorf("names: validate: name %q exceeds %d characters", name, maxNameLen)
95+ }
96+ if !validPattern.MatchString(name) {
97+ return fmt.Errorf("names: validate: name %q must match %s", name, validPattern.String())
98+ }
99+ if reserved[name] {
100+ return fmt.Errorf("names: validate: name %q is reserved", name)
101+ }
102+ return nil
103+}
internal/names/names_test.go+69−0
@@ -0,0 +1,69 @@
1+package names
2+
3+import "testing"
4+
5+func TestGenerate(t *testing.T) {
6+ taken := map[string]bool{}
7+ name, err := Generate(taken)
8+ if err != nil {
9+ t.Fatalf("Generate: %v", err)
10+ }
11+ if err := Validate(name); err != nil {
12+ t.Fatalf("Generate produced invalid name %q: %v", name, err)
13+ }
14+ taken[name] = true
15+ name2, err := Generate(taken)
16+ if err != nil {
17+ t.Fatalf("Generate: %v", err)
18+ }
19+ if name2 == name {
20+ t.Fatalf("Generate: returned already-taken name %q", name)
21+ }
22+}
23+
24+// TestGenerate_NotAlwaysTheSameFirstName guards against the original bug
25+// where Generate always walked the adjective x animal product in the same
26+// fixed order, so the very first box ever created (empty taken map) always
27+// got the same name. With randomized picks, repeated calls against a fresh
28+// empty map should not all agree.
29+func TestGenerate_NotAlwaysTheSameFirstName(t *testing.T) {
30+ first, err := Generate(map[string]bool{})
31+ if err != nil {
32+ t.Fatalf("Generate: %v", err)
33+ }
34+ for i := 0; i < 50; i++ {
35+ name, err := Generate(map[string]bool{})
36+ if err != nil {
37+ t.Fatalf("Generate: %v", err)
38+ }
39+ if name != first {
40+ return
41+ }
42+ }
43+ t.Fatalf("Generate returned %q on every one of 51 calls against an empty taken map; expected randomization", first)
44+}
45+
46+func TestValidate(t *testing.T) {
47+ cases := []struct {
48+ name string
49+ ok bool
50+ }{
51+ {"large-cat", true},
52+ {"my_box", true},
53+ {"box9", true},
54+ {"", false},
55+ {"Bad-Case", false},
56+ {"-leading-dash", false},
57+ {"create", false},
58+ {"way-too-long-name-that-exceeds-the-maximum-length-allowed", false},
59+ }
60+ for _, c := range cases {
61+ err := Validate(c.name)
62+ if c.ok && err != nil {
63+ t.Errorf("Validate(%q): want ok, got %v", c.name, err)
64+ }
65+ if !c.ok && err == nil {
66+ t.Errorf("Validate(%q): want error, got nil", c.name)
67+ }
68+ }
69+}
internal/selfupdate/asset.go+12−0
@@ -0,0 +1,12 @@
1+package selfupdate
2+
3+import "fmt"
4+
5+// AssetName returns the release asset name tx9 publishes for a given
6+// GOOS/GOARCH pair: a plain, uncompressed executable, no extension. This
7+// is the one naming invariant shared by this package, the Makefile's
8+// `dist` target, and .github/workflows/release.yml — all three must agree,
9+// or self-update silently can't find its own binary.
10+func AssetName(goos, goarch string) string {
11+ return fmt.Sprintf("tx9_%s_%s", goos, goarch)
12+}
internal/selfupdate/asset_test.go+19−0
@@ -0,0 +1,19 @@
1+package selfupdate
2+
3+import "testing"
4+
5+func TestAssetName(t *testing.T) {
6+ cases := []struct {
7+ goos, goarch, want string
8+ }{
9+ {"linux", "amd64", "tx9_linux_amd64"},
10+ {"linux", "arm64", "tx9_linux_arm64"},
11+ {"darwin", "amd64", "tx9_darwin_amd64"},
12+ {"darwin", "arm64", "tx9_darwin_arm64"},
13+ }
14+ for _, c := range cases {
15+ if got := AssetName(c.goos, c.goarch); got != c.want {
16+ t.Errorf("AssetName(%q, %q) = %q, want %q", c.goos, c.goarch, got, c.want)
17+ }
18+ }
19+}
internal/selfupdate/checksums.go+54−0
@@ -0,0 +1,54 @@
1+package selfupdate
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "strings"
7+)
8+
9+// ParseChecksums parses a checksums.txt in the standard `sha256sum` output
10+// format — one "<64-hex-char-digest> <filename>" pair per line (the
11+// classic tool emits two spaces; a single space or tab is also accepted)
12+// — and returns a filename -> lowercase hex digest map. Blank lines and
13+// "#"-prefixed comment lines are ignored, so the release workflow is free
14+// to add a header.
15+func ParseChecksums(data []byte) (map[string]string, error) {
16+ out := make(map[string]string)
17+ sc := bufio.NewScanner(strings.NewReader(string(data)))
18+ for sc.Scan() {
19+ line := strings.TrimSpace(sc.Text())
20+ if line == "" || strings.HasPrefix(line, "#") {
21+ continue
22+ }
23+ fields := strings.Fields(line)
24+ if len(fields) != 2 {
25+ return nil, fmt.Errorf("checksums: malformed line %q (want \"<digest> <filename>\")", line)
26+ }
27+ digest := strings.ToLower(fields[0])
28+ // sha256sum -b (binary mode) prefixes the filename with "*".
29+ name := strings.TrimPrefix(fields[1], "*")
30+ if len(digest) != 64 || !isHex(digest) {
31+ return nil, fmt.Errorf("checksums: %q: digest %q is not 64 hex chars", name, fields[0])
32+ }
33+ if name == "" {
34+ return nil, fmt.Errorf("checksums: malformed line %q (empty filename)", line)
35+ }
36+ if _, dup := out[name]; dup {
37+ return nil, fmt.Errorf("checksums: duplicate entry for %q", name)
38+ }
39+ out[name] = digest
40+ }
41+ if err := sc.Err(); err != nil {
42+ return nil, fmt.Errorf("checksums: %w", err)
43+ }
44+ return out, nil
45+}
46+
47+func isHex(s string) bool {
48+ for _, c := range s {
49+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
50+ return false
51+ }
52+ }
53+ return true
54+}
internal/selfupdate/checksums_test.go+66−0
@@ -0,0 +1,66 @@
1+package selfupdate
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestParseChecksums(t *testing.T) {
9+ digestA := strings.Repeat("a", 64)
10+ digestB := strings.Repeat("b", 64)
11+ data := "# checksums for v0.2.0\n" +
12+ digestA + " tx9_linux_amd64\n" +
13+ "\n" +
14+ digestB + " tx9_darwin_arm64\n"
15+
16+ sums, err := ParseChecksums([]byte(data))
17+ if err != nil {
18+ t.Fatalf("ParseChecksums: %v", err)
19+ }
20+ if sums["tx9_linux_amd64"] != digestA {
21+ t.Errorf("tx9_linux_amd64 = %q, want %q", sums["tx9_linux_amd64"], digestA)
22+ }
23+ if sums["tx9_darwin_arm64"] != digestB {
24+ t.Errorf("tx9_darwin_arm64 = %q, want %q", sums["tx9_darwin_arm64"], digestB)
25+ }
26+ if len(sums) != 2 {
27+ t.Errorf("len(sums) = %d, want 2", len(sums))
28+ }
29+}
30+
31+func TestParseChecksumsBinaryModeStar(t *testing.T) {
32+ digest := strings.Repeat("c", 64)
33+ sums, err := ParseChecksums([]byte(digest + " *tx9_linux_amd64\n"))
34+ if err != nil {
35+ t.Fatalf("ParseChecksums: %v", err)
36+ }
37+ if sums["tx9_linux_amd64"] != digest {
38+ t.Errorf("tx9_linux_amd64 = %q, want %q", sums["tx9_linux_amd64"], digest)
39+ }
40+}
41+
42+func TestParseChecksumsRejectsBadDigest(t *testing.T) {
43+ if _, err := ParseChecksums([]byte("not-a-digest tx9_linux_amd64\n")); err == nil {
44+ t.Fatal("ParseChecksums: want error for non-hex digest, got nil")
45+ }
46+ if _, err := ParseChecksums([]byte(strings.Repeat("a", 63) + " tx9_linux_amd64\n")); err == nil {
47+ t.Fatal("ParseChecksums: want error for short digest, got nil")
48+ }
49+}
50+
51+func TestParseChecksumsRejectsMalformedLine(t *testing.T) {
52+ if _, err := ParseChecksums([]byte("just-one-field\n")); err == nil {
53+ t.Fatal("ParseChecksums: want error for one-field line, got nil")
54+ }
55+ if _, err := ParseChecksums([]byte("a b c\n")); err == nil {
56+ t.Fatal("ParseChecksums: want error for three-field line, got nil")
57+ }
58+}
59+
60+func TestParseChecksumsRejectsDuplicate(t *testing.T) {
61+ digest := strings.Repeat("a", 64)
62+ data := digest + " tx9_linux_amd64\n" + digest + " tx9_linux_amd64\n"
63+ if _, err := ParseChecksums([]byte(data)); err == nil {
64+ t.Fatal("ParseChecksums: want error for duplicate entry, got nil")
65+ }
66+}
internal/selfupdate/github.go+93−0
@@ -0,0 +1,93 @@
1+package selfupdate
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "io"
7+ "net/http"
8+ "os"
9+ "strings"
10+)
11+
12+// Repo is the GitHub repository release assets are published to.
13+const Repo = "davis7dotsh/tx9"
14+
15+// ErrNoRelease indicates the repository has no published releases yet.
16+// GitHub's /releases/latest returns 404 in that case, which is the
17+// expected state until the first `v*` tag is pushed (see
18+// .github/workflows/release.yml) — callers should surface this as a
19+// clean, actionable message rather than a raw HTTP error.
20+var ErrNoRelease = fmt.Errorf("no releases published yet")
21+
22+// Asset is one file attached to a GitHub release.
23+type Asset struct {
24+ Name string `json:"name"`
25+ BrowserDownloadURL string `json:"browser_download_url"`
26+}
27+
28+// Release is the subset of the GitHub releases API response this package
29+// needs.
30+type Release struct {
31+ TagName string `json:"tag_name"`
32+ Assets []Asset `json:"assets"`
33+}
34+
35+// FindAsset returns the asset in r named name, or false if absent.
36+func (r *Release) FindAsset(name string) (Asset, bool) {
37+ for _, a := range r.Assets {
38+ if a.Name == name {
39+ return a, true
40+ }
41+ }
42+ return Asset{}, false
43+}
44+
45+func (r *Release) assetNames() string {
46+ names := make([]string, len(r.Assets))
47+ for i, a := range r.Assets {
48+ names[i] = a.Name
49+ }
50+ return strings.Join(names, ", ")
51+}
52+
53+// FetchLatestRelease queries the GitHub API for Repo's latest release. It
54+// honors the GITHUB_TOKEN environment variable (if set) as a bearer
55+// credential on the API call only, to avoid the low unauthenticated rate
56+// limit — it is deliberately NOT attached to asset download requests,
57+// since browser_download_url responses are signed redirects that an extra
58+// Authorization header can break.
59+func FetchLatestRelease(httpClient *http.Client) (*Release, error) {
60+ url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", Repo)
61+ req, err := http.NewRequest(http.MethodGet, url, nil)
62+ if err != nil {
63+ return nil, fmt.Errorf("selfupdate: build request: %w", err)
64+ }
65+ req.Header.Set("User-Agent", userAgent)
66+ req.Header.Set("Accept", "application/vnd.github+json")
67+ if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
68+ req.Header.Set("Authorization", "Bearer "+tok)
69+ }
70+
71+ resp, err := httpClient.Do(req)
72+ if err != nil {
73+ return nil, fmt.Errorf("selfupdate: fetch latest release: %w", err)
74+ }
75+ defer resp.Body.Close()
76+
77+ if resp.StatusCode == http.StatusNotFound {
78+ return nil, ErrNoRelease
79+ }
80+ if resp.StatusCode != http.StatusOK {
81+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
82+ return nil, fmt.Errorf("selfupdate: GitHub API returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
83+ }
84+
85+ var rel Release
86+ if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
87+ return nil, fmt.Errorf("selfupdate: decode release response: %w", err)
88+ }
89+ if rel.TagName == "" {
90+ return nil, fmt.Errorf("selfupdate: release response missing tag_name")
91+ }
92+ return &rel, nil
93+}
internal/selfupdate/selfupdate.go+240−0
@@ -0,0 +1,240 @@
1+// Package selfupdate implements `tx9 upgrade` with no box argument
2+// (docs/tx9-cli-design.md decision 11): it queries GitHub Releases for the
3+// latest tagged tx9 release, compares it against the running binary's
4+// version, downloads and checksum-verifies the matching platform asset,
5+// and atomically replaces the currently running executable.
6+//
7+// Asset naming (the one invariant shared across this package, the
8+// Makefile's `dist` target, and .github/workflows/release.yml):
9+// plain, uncompressed executables named "tx9_<GOOS>_<GOARCH>" (see
10+// AssetName), plus a "checksums.txt" asset in `sha256sum` output format
11+// (see ParseChecksums). Changing this naming means changing it in all
12+// three places.
13+package selfupdate
14+
15+import (
16+ "crypto/sha256"
17+ "encoding/hex"
18+ "fmt"
19+ "io"
20+ "net/http"
21+ "os"
22+ "path/filepath"
23+ "runtime"
24+ "time"
25+)
26+
27+const (
28+ checksumsAssetName = "checksums.txt"
29+ userAgent = "tx9-selfupdate"
30+)
31+
32+// Options configures Update.
33+type Options struct {
34+ // CurrentVersion is the running binary's version (internal/version.Version).
35+ CurrentVersion string
36+ // Force skips the dev-version guard and the already-up-to-date
37+ // short-circuit, and re-installs even the same version.
38+ Force bool
39+ // GOOS/GOARCH override runtime.GOOS/GOARCH. Tests only; zero value
40+ // means "use the running binary's platform".
41+ GOOS, GOARCH string
42+ // HTTPClient overrides the default client used for both the GitHub
43+ // API call and the asset downloads. Tests only; zero value means
44+ // "use a client with a sane timeout".
45+ HTTPClient *http.Client
46+ // Out receives human-readable progress lines. Defaults to io.Discard.
47+ Out io.Writer
48+
49+ // execPathOverride replaces the os.Executable()+EvalSymlinks lookup.
50+ // Tests only (unexported): Update must never overwrite the actual
51+ // running test binary.
52+ execPathOverride string
53+}
54+
55+// Result summarizes a completed (or skipped) update.
56+type Result struct {
57+ // Applied is false when the binary was already at the latest version
58+ // (and Force was not set) — nothing was downloaded or replaced.
59+ Applied bool
60+ // FromVersion is the version that was running before Update was called.
61+ FromVersion string
62+ // ToVersion is the release tag_name (with leading "v" kept as published).
63+ ToVersion string
64+ // InstalledPath is the executable path that was replaced. Empty when
65+ // Applied is false.
66+ InstalledPath string
67+}
68+
69+// ErrDevVersion is returned when CurrentVersion is "dev" (an unversioned
70+// `go build`/`go run`) and Force is not set — there is no meaningful
71+// "latest" to compare a dev build against, and self-replacing a
72+// developer's working binary without asking is surprising.
73+var ErrDevVersion = fmt.Errorf("running a dev build (no version baked in); build a release or pass --force")
74+
75+// Update runs the full self-update flow described in the package doc.
76+func Update(opts Options) (*Result, error) {
77+ out := opts.Out
78+ if out == nil {
79+ out = io.Discard
80+ }
81+
82+ if opts.CurrentVersion == "" || opts.CurrentVersion == "dev" {
83+ if !opts.Force {
84+ return nil, ErrDevVersion
85+ }
86+ }
87+
88+ client := opts.HTTPClient
89+ if client == nil {
90+ client = &http.Client{Timeout: 30 * time.Second}
91+ }
92+
93+ fmt.Fprintln(out, "tx9: checking latest release...")
94+ rel, err := FetchLatestRelease(client)
95+ if err != nil {
96+ return nil, err
97+ }
98+
99+ if !opts.Force && SameVersion(opts.CurrentVersion, rel.TagName) {
100+ return &Result{Applied: false, FromVersion: opts.CurrentVersion, ToVersion: rel.TagName}, nil
101+ }
102+
103+ goos, goarch := opts.GOOS, opts.GOARCH
104+ if goos == "" {
105+ goos = runtime.GOOS
106+ }
107+ if goarch == "" {
108+ goarch = runtime.GOARCH
109+ }
110+ assetName := AssetName(goos, goarch)
111+
112+ asset, ok := rel.FindAsset(assetName)
113+ if !ok {
114+ return nil, fmt.Errorf("selfupdate: release %s has no asset named %q (have: %s)", rel.TagName, assetName, rel.assetNames())
115+ }
116+ checksumsAsset, ok := rel.FindAsset(checksumsAssetName)
117+ if !ok {
118+ return nil, fmt.Errorf("selfupdate: release %s is missing %s, can't verify %q", rel.TagName, checksumsAssetName, assetName)
119+ }
120+
121+ // Resolve the running executable's real path before touching the
122+ // network for the (larger) binary asset, so a broken install layout
123+ // fails fast instead of after a wasted download.
124+ realPath := opts.execPathOverride
125+ if realPath == "" {
126+ exePath, err := os.Executable()
127+ if err != nil {
128+ return nil, fmt.Errorf("selfupdate: locate running executable: %w", err)
129+ }
130+ realPath, err = filepath.EvalSymlinks(exePath)
131+ if err != nil {
132+ return nil, fmt.Errorf("selfupdate: resolve running executable %q: %w", exePath, err)
133+ }
134+ }
135+
136+ fmt.Fprintf(out, "tx9: fetching checksums for %s...\n", rel.TagName)
137+ checksumsData, err := fetchBytes(client, checksumsAsset.BrowserDownloadURL)
138+ if err != nil {
139+ return nil, fmt.Errorf("selfupdate: download %s: %w", checksumsAssetName, err)
140+ }
141+ sums, err := ParseChecksums(checksumsData)
142+ if err != nil {
143+ return nil, fmt.Errorf("selfupdate: parse %s: %w", checksumsAssetName, err)
144+ }
145+ wantDigest, ok := sums[assetName]
146+ if !ok {
147+ return nil, fmt.Errorf("selfupdate: %s has no checksum entry for %q", checksumsAssetName, assetName)
148+ }
149+
150+ stagePath := realPath + ".new"
151+ fmt.Fprintf(out, "tx9: downloading %s %s -> %s\n", assetName, rel.TagName, stagePath)
152+ gotDigest, err := downloadToFile(client, asset.BrowserDownloadURL, stagePath)
153+ if err != nil {
154+ os.Remove(stagePath)
155+ return nil, fmt.Errorf("selfupdate: download %s: %w", assetName, err)
156+ }
157+ if gotDigest != wantDigest {
158+ os.Remove(stagePath)
159+ return nil, fmt.Errorf("selfupdate: checksum mismatch for %s: got %s, want %s (old binary untouched)", assetName, gotDigest, wantDigest)
160+ }
161+
162+ if err := os.Chmod(stagePath, 0o755); err != nil {
163+ os.Remove(stagePath)
164+ return nil, fmt.Errorf("selfupdate: chmod staged binary: %w (old binary untouched)", err)
165+ }
166+
167+ // stagePath is a sibling of realPath (same directory), so this rename
168+ // is same-filesystem and atomic. On Linux, renaming over the binary a
169+ // process is currently executing is safe: the running process keeps
170+ // its already-mapped inode; the new inode simply takes the path.
171+ if err := os.Rename(stagePath, realPath); err != nil {
172+ os.Remove(stagePath)
173+ return nil, fmt.Errorf("selfupdate: install new binary at %s: %w (old binary untouched)", realPath, err)
174+ }
175+
176+ fmt.Fprintf(out, "tx9: installed %s at %s\n", rel.TagName, realPath)
177+ return &Result{
178+ Applied: true,
179+ FromVersion: opts.CurrentVersion,
180+ ToVersion: rel.TagName,
181+ InstalledPath: realPath,
182+ }, nil
183+}
184+
185+// fetchBytes downloads url and returns its full body. Used for the small
186+// checksums.txt asset.
187+func fetchBytes(client *http.Client, url string) ([]byte, error) {
188+ req, err := http.NewRequest(http.MethodGet, url, nil)
189+ if err != nil {
190+ return nil, err
191+ }
192+ req.Header.Set("User-Agent", userAgent)
193+
194+ resp, err := client.Do(req)
195+ if err != nil {
196+ return nil, err
197+ }
198+ defer resp.Body.Close()
199+
200+ if resp.StatusCode != http.StatusOK {
201+ return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
202+ }
203+ return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MiB is generous for a checksums file
204+}
205+
206+// downloadToFile streams url's body to destPath (created/truncated,
207+// mode 0644 pending the caller's chmod) while hashing it, and returns the
208+// lowercase hex sha256 digest of what was written.
209+func downloadToFile(client *http.Client, url, destPath string) (string, error) {
210+ req, err := http.NewRequest(http.MethodGet, url, nil)
211+ if err != nil {
212+ return "", err
213+ }
214+ req.Header.Set("User-Agent", userAgent)
215+
216+ resp, err := client.Do(req)
217+ if err != nil {
218+ return "", err
219+ }
220+ defer resp.Body.Close()
221+
222+ if resp.StatusCode != http.StatusOK {
223+ return "", fmt.Errorf("GET %s: %s", url, resp.Status)
224+ }
225+
226+ f, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
227+ if err != nil {
228+ return "", err
229+ }
230+ defer f.Close()
231+
232+ h := sha256.New()
233+ if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil {
234+ return "", err
235+ }
236+ if err := f.Close(); err != nil {
237+ return "", err
238+ }
239+ return hex.EncodeToString(h.Sum(nil)), nil
240+}
internal/selfupdate/selfupdate_test.go+216−0
@@ -0,0 +1,216 @@
1+package selfupdate
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "encoding/json"
7+ "fmt"
8+ "net/http"
9+ "net/http/httptest"
10+ "os"
11+ "path/filepath"
12+ "strings"
13+ "testing"
14+)
15+
16+// newTestServer serves a fake GitHub releases API + asset downloads for
17+// one release. assetContent is the payload for the platform binary asset
18+// named tx9_<goos>_<goarch>; checksums.txt is generated to match it.
19+func newTestServer(t *testing.T, tag, goos, goarch string, assetContent []byte) *httptest.Server {
20+ t.Helper()
21+ assetName := AssetName(goos, goarch)
22+ sum := sha256.Sum256(assetContent)
23+ checksums := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), assetName)
24+
25+ mux := http.NewServeMux()
26+ var srv *httptest.Server
27+ mux.HandleFunc("/repos/"+Repo+"/releases/latest", func(w http.ResponseWriter, r *http.Request) {
28+ rel := Release{
29+ TagName: tag,
30+ Assets: []Asset{
31+ {Name: assetName, BrowserDownloadURL: srv.URL + "/download/" + assetName},
32+ {Name: checksumsAssetName, BrowserDownloadURL: srv.URL + "/download/" + checksumsAssetName},
33+ },
34+ }
35+ w.Header().Set("Content-Type", "application/json")
36+ _ = json.NewEncoder(w).Encode(rel)
37+ })
38+ mux.HandleFunc("/download/"+assetName, func(w http.ResponseWriter, r *http.Request) {
39+ _, _ = w.Write(assetContent)
40+ })
41+ mux.HandleFunc("/download/"+checksumsAssetName, func(w http.ResponseWriter, r *http.Request) {
42+ _, _ = w.Write([]byte(checksums))
43+ })
44+ srv = httptest.NewServer(mux)
45+ t.Cleanup(srv.Close)
46+ return srv
47+}
48+
49+// apiURLClient points FetchLatestRelease's github.com/api URL at our test
50+// server by overriding the transport's DialContext... Instead of faking
51+// DNS, selfupdate.go builds the URL from the Repo constant against a fixed
52+// host, so tests use a small indirection: an http.Client whose Transport
53+// rewrites api.github.com requests to the test server.
54+type rewriteTransport struct {
55+ target string
56+ rt http.RoundTripper
57+}
58+
59+func (rt rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
60+ if req.URL.Host == "api.github.com" {
61+ req.URL.Scheme = "http"
62+ req.URL.Host = rt.target[len("http://"):]
63+ }
64+ return rt.rt.RoundTrip(req)
65+}
66+
67+func clientFor(srv *httptest.Server) *http.Client {
68+ return &http.Client{Transport: rewriteTransport{target: srv.URL, rt: http.DefaultTransport}}
69+}
70+
71+func TestUpdateAlreadyUpToDate(t *testing.T) {
72+ srv := newTestServer(t, "v1.2.3", "linux", "amd64", []byte("binary contents"))
73+ res, err := Update(Options{
74+ CurrentVersion: "1.2.3",
75+ GOOS: "linux",
76+ GOARCH: "amd64",
77+ HTTPClient: clientFor(srv),
78+ })
79+ if err != nil {
80+ t.Fatalf("Update: %v", err)
81+ }
82+ if res.Applied {
83+ t.Fatal("Update: Applied = true, want false (already up to date)")
84+ }
85+}
86+
87+func TestUpdateDevVersionRefusesWithoutForce(t *testing.T) {
88+ _, err := Update(Options{CurrentVersion: "dev"})
89+ if err != ErrDevVersion {
90+ t.Fatalf("Update: err = %v, want ErrDevVersion", err)
91+ }
92+}
93+
94+func TestUpdateDownloadsVerifiesAndReplaces(t *testing.T) {
95+ content := []byte("new binary contents v1.2.4")
96+ srv := newTestServer(t, "v1.2.4", "linux", "amd64", content)
97+
98+ dir := t.TempDir()
99+ exePath := filepath.Join(dir, "tx9")
100+ if err := os.WriteFile(exePath, []byte("old binary contents"), 0o755); err != nil {
101+ t.Fatalf("seed old binary: %v", err)
102+ }
103+
104+ opts := Options{
105+ CurrentVersion: "1.2.3",
106+ GOOS: "linux",
107+ GOARCH: "amd64",
108+ HTTPClient: clientFor(srv),
109+ execPathOverride: exePath,
110+ }
111+ res, err := Update(opts)
112+ if err != nil {
113+ t.Fatalf("Update: %v", err)
114+ }
115+ if !res.Applied {
116+ t.Fatal("Update: Applied = false, want true")
117+ }
118+ if res.ToVersion != "v1.2.4" {
119+ t.Errorf("Update: ToVersion = %q, want v1.2.4", res.ToVersion)
120+ }
121+ if res.InstalledPath != exePath {
122+ t.Errorf("Update: InstalledPath = %q, want %q", res.InstalledPath, exePath)
123+ }
124+
125+ got, err := os.ReadFile(exePath)
126+ if err != nil {
127+ t.Fatalf("read installed binary: %v", err)
128+ }
129+ if string(got) != string(content) {
130+ t.Errorf("installed binary = %q, want %q", got, content)
131+ }
132+ info, err := os.Stat(exePath)
133+ if err != nil {
134+ t.Fatalf("stat installed binary: %v", err)
135+ }
136+ if info.Mode().Perm() != 0o755 {
137+ t.Errorf("installed binary mode = %o, want 0755", info.Mode().Perm())
138+ }
139+ if _, err := os.Stat(exePath + ".new"); !os.IsNotExist(err) {
140+ t.Errorf("stage file %s.new should be gone after a successful rename", exePath)
141+ }
142+}
143+
144+func TestUpdateChecksumMismatchLeavesOldBinaryUntouched(t *testing.T) {
145+ dir := t.TempDir()
146+ exePath := filepath.Join(dir, "tx9")
147+ original := []byte("old binary contents")
148+ if err := os.WriteFile(exePath, original, 0o755); err != nil {
149+ t.Fatalf("seed old binary: %v", err)
150+ }
151+
152+ // A server whose checksums.txt deliberately doesn't match the asset
153+ // it describes, to exercise the verify-before-install path.
154+ assetName := AssetName("linux", "amd64")
155+ badChecksum := strings.Repeat("0", 64)
156+ mux := http.NewServeMux()
157+ var srv *httptest.Server
158+ mux.HandleFunc("/repos/"+Repo+"/releases/latest", func(w http.ResponseWriter, r *http.Request) {
159+ rel := Release{
160+ TagName: "v1.2.4",
161+ Assets: []Asset{
162+ {Name: assetName, BrowserDownloadURL: srv.URL + "/download/" + assetName},
163+ {Name: checksumsAssetName, BrowserDownloadURL: srv.URL + "/download/checksums.txt"},
164+ },
165+ }
166+ w.Header().Set("Content-Type", "application/json")
167+ _ = json.NewEncoder(w).Encode(rel)
168+ })
169+ mux.HandleFunc("/download/"+assetName, func(w http.ResponseWriter, r *http.Request) {
170+ _, _ = w.Write([]byte("new binary contents"))
171+ })
172+ mux.HandleFunc("/download/checksums.txt", func(w http.ResponseWriter, r *http.Request) {
173+ _, _ = w.Write([]byte(badChecksum + " " + assetName + "\n"))
174+ })
175+ srv = httptest.NewServer(mux)
176+ t.Cleanup(srv.Close)
177+
178+ _, err := Update(Options{
179+ CurrentVersion: "1.2.3",
180+ GOOS: "linux",
181+ GOARCH: "amd64",
182+ HTTPClient: clientFor(srv),
183+ execPathOverride: exePath,
184+ })
185+ if err == nil {
186+ t.Fatal("Update: want checksum mismatch error, got nil")
187+ }
188+
189+ got, err := os.ReadFile(exePath)
190+ if err != nil {
191+ t.Fatalf("read binary after failed update: %v", err)
192+ }
193+ if string(got) != string(original) {
194+ t.Errorf("binary was modified despite checksum mismatch: got %q, want unchanged %q", got, original)
195+ }
196+ if _, err := os.Stat(exePath + ".new"); !os.IsNotExist(err) {
197+ t.Errorf("stage file %s.new should be cleaned up after a failed update", exePath)
198+ }
199+}
200+
201+func TestUpdateNoReleaseYet(t *testing.T) {
202+ mux := http.NewServeMux()
203+ mux.HandleFunc("/repos/"+Repo+"/releases/latest", func(w http.ResponseWriter, r *http.Request) {
204+ w.WriteHeader(http.StatusNotFound)
205+ })
206+ srv := httptest.NewServer(mux)
207+ t.Cleanup(srv.Close)
208+
209+ _, err := Update(Options{
210+ CurrentVersion: "1.2.3",
211+ HTTPClient: clientFor(srv),
212+ })
213+ if err != ErrNoRelease {
214+ t.Fatalf("Update: err = %v, want ErrNoRelease", err)
215+ }
216+}
internal/selfupdate/version.go+73−0
@@ -0,0 +1,73 @@
1+package selfupdate
2+
3+import (
4+ "strconv"
5+ "strings"
6+)
7+
8+// normalizeVersion strips a leading "v" (GitHub tags look like "v0.2.0";
9+// internal/version.Version has no leading v) and surrounding whitespace.
10+func normalizeVersion(v string) string {
11+ return strings.TrimPrefix(strings.TrimSpace(v), "v")
12+}
13+
14+// SameVersion reports whether a and b denote the same version once
15+// normalized (leading "v" stripped, dotted-numeric components compared
16+// numerically so "0.2" == "0.2.0"). Falls back to a normalized string
17+// compare for anything that doesn't parse as dotted integers, so an
18+// unexpected tag format never panics — it just compares unequal to
19+// anything else unexpected.
20+func SameVersion(a, b string) bool {
21+ return CompareVersions(a, b) == 0
22+}
23+
24+// CompareVersions compares two dotted-numeric version strings (a leading
25+// "v" is tolerated on either side). It returns -1 if a<b, 0 if equal, and 1
26+// if a>b. Missing trailing components compare as 0 ("1.2" == "1.2.0").
27+// If either side doesn't parse as dotted integers, it falls back to a
28+// plain string comparison of the normalized strings.
29+func CompareVersions(a, b string) int {
30+ na, nb := normalizeVersion(a), normalizeVersion(b)
31+ if na == nb {
32+ return 0
33+ }
34+ pa, oka := splitNumeric(na)
35+ pb, okb := splitNumeric(nb)
36+ if !oka || !okb {
37+ return strings.Compare(na, nb)
38+ }
39+ for i := 0; i < len(pa) || i < len(pb); i++ {
40+ var va, vb int
41+ if i < len(pa) {
42+ va = pa[i]
43+ }
44+ if i < len(pb) {
45+ vb = pb[i]
46+ }
47+ if va != vb {
48+ if va < vb {
49+ return -1
50+ }
51+ return 1
52+ }
53+ }
54+ return 0
55+}
56+
57+// splitNumeric splits a dotted version string into integer components,
58+// reporting false if any component isn't a non-negative integer.
59+func splitNumeric(v string) ([]int, bool) {
60+ if v == "" {
61+ return nil, false
62+ }
63+ parts := strings.Split(v, ".")
64+ out := make([]int, len(parts))
65+ for i, p := range parts {
66+ n, err := strconv.Atoi(p)
67+ if err != nil || n < 0 {
68+ return nil, false
69+ }
70+ out[i] = n
71+ }
72+ return out, true
73+}
internal/selfupdate/version_test.go+50−0
@@ -0,0 +1,50 @@
1+package selfupdate
2+
3+import "testing"
4+
5+func TestCompareVersions(t *testing.T) {
6+ cases := []struct {
7+ a, b string
8+ want int
9+ }{
10+ {"1.2.3", "1.2.3", 0},
11+ {"v1.2.3", "1.2.3", 0},
12+ {"1.2.3", "v1.2.3", 0},
13+ {"0.2.0", "0.10.0", -1},
14+ {"0.10.0", "0.2.0", 1},
15+ {"1.2", "1.2.0", 0},
16+ {"1.2.0", "1.2", 0},
17+ {"1.2.1", "1.2", 1},
18+ {"1.2", "1.2.1", -1},
19+ {"2.0.0", "1.9.9", 1},
20+ {"1.9.9", "2.0.0", -1},
21+ }
22+ for _, c := range cases {
23+ if got := CompareVersions(c.a, c.b); got != c.want {
24+ t.Errorf("CompareVersions(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
25+ }
26+ }
27+}
28+
29+func TestCompareVersionsNonNumericFallsBackToStringCompare(t *testing.T) {
30+ // Doesn't parse as dotted integers on either side (or both) -- must
31+ // not panic, and equal normalized strings must still compare equal.
32+ if CompareVersions("dev", "dev") != 0 {
33+ t.Error(`CompareVersions("dev", "dev") != 0`)
34+ }
35+ if CompareVersions("dev", "0.1.0") == 0 {
36+ t.Error(`CompareVersions("dev", "0.1.0") unexpectedly equal`)
37+ }
38+}
39+
40+func TestSameVersion(t *testing.T) {
41+ if !SameVersion("v0.2.0", "0.2.0") {
42+ t.Error(`SameVersion("v0.2.0", "0.2.0") = false, want true`)
43+ }
44+ if SameVersion("v0.2.0", "0.2.1") {
45+ t.Error(`SameVersion("v0.2.0", "0.2.1") = true, want false`)
46+ }
47+ if SameVersion("dev", "0.2.0") {
48+ t.Error(`SameVersion("dev", "0.2.0") = true, want false`)
49+ }
50+}
internal/state/state.go+138−0
@@ -0,0 +1,138 @@
1+// Package state manages tx9's on-host state directory (~/.tx9).
2+package state
3+
4+import (
5+ "bufio"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "sort"
10+ "strings"
11+)
12+
13+// Dir returns ~/.tx9, creating it (mode 0700) if absent.
14+func Dir() (string, error) {
15+ home, err := os.UserHomeDir()
16+ if err != nil {
17+ return "", fmt.Errorf("state: dir: %w", err)
18+ }
19+ dir := filepath.Join(home, ".tx9")
20+ if err := os.MkdirAll(dir, 0o700); err != nil {
21+ return "", fmt.Errorf("state: dir: %w", err)
22+ }
23+ return dir, nil
24+}
25+
26+// BoxesDir returns ~/.tx9/boxes, creating it if absent.
27+func BoxesDir() (string, error) {
28+ dir, err := Dir()
29+ if err != nil {
30+ return "", err
31+ }
32+ d := filepath.Join(dir, "boxes")
33+ if err := os.MkdirAll(d, 0o700); err != nil {
34+ return "", fmt.Errorf("state: boxesDir: %w", err)
35+ }
36+ return d, nil
37+}
38+
39+// LocksDir returns ~/.tx9/locks, creating it if absent.
40+func LocksDir() (string, error) {
41+ dir, err := Dir()
42+ if err != nil {
43+ return "", err
44+ }
45+ d := filepath.Join(dir, "locks")
46+ if err := os.MkdirAll(d, 0o700); err != nil {
47+ return "", fmt.Errorf("state: locksDir: %w", err)
48+ }
49+ return d, nil
50+}
51+
52+// BoxEnvPath returns ~/.tx9/boxes/<name>.env.
53+func BoxEnvPath(name string) (string, error) {
54+ dir, err := BoxesDir()
55+ if err != nil {
56+ return "", err
57+ }
58+ return filepath.Join(dir, name+".env"), nil
59+}
60+
61+// LockPath returns ~/.tx9/locks/<name>.lock.
62+func LockPath(name string) (string, error) {
63+ dir, err := LocksDir()
64+ if err != nil {
65+ return "", err
66+ }
67+ return filepath.Join(dir, name+".lock"), nil
68+}
69+
70+// ReadBoxEnv reads a KEY=VALUE env file into a map. Returns an empty map
71+// (no error) if the file does not exist.
72+func ReadBoxEnv(name string) (map[string]string, error) {
73+ path, err := BoxEnvPath(name)
74+ if err != nil {
75+ return nil, err
76+ }
77+ f, err := os.Open(path)
78+ if err != nil {
79+ if os.IsNotExist(err) {
80+ return map[string]string{}, nil
81+ }
82+ return nil, fmt.Errorf("state: readBoxEnv: %w", err)
83+ }
84+ defer f.Close()
85+
86+ env := map[string]string{}
87+ scanner := bufio.NewScanner(f)
88+ for scanner.Scan() {
89+ line := strings.TrimSpace(scanner.Text())
90+ if line == "" || strings.HasPrefix(line, "#") {
91+ continue
92+ }
93+ k, v, ok := strings.Cut(line, "=")
94+ if !ok {
95+ continue
96+ }
97+ env[k] = v
98+ }
99+ if err := scanner.Err(); err != nil {
100+ return nil, fmt.Errorf("state: readBoxEnv: %w", err)
101+ }
102+ return env, nil
103+}
104+
105+// WriteBoxEnv writes a KEY=VALUE env file (mode 0600), overwriting any
106+// existing file.
107+func WriteBoxEnv(name string, env map[string]string) error {
108+ path, err := BoxEnvPath(name)
109+ if err != nil {
110+ return err
111+ }
112+ keys := make([]string, 0, len(env))
113+ for k := range env {
114+ keys = append(keys, k)
115+ }
116+ sort.Strings(keys)
117+
118+ var b strings.Builder
119+ for _, k := range keys {
120+ fmt.Fprintf(&b, "%s=%s\n", k, env[k])
121+ }
122+ if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
123+ return fmt.Errorf("state: writeBoxEnv: %w", err)
124+ }
125+ return nil
126+}
127+
128+// RemoveBoxEnv deletes ~/.tx9/boxes/<name>.env, if present.
129+func RemoveBoxEnv(name string) error {
130+ path, err := BoxEnvPath(name)
131+ if err != nil {
132+ return err
133+ }
134+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
135+ return fmt.Errorf("state: removeBoxEnv: %w", err)
136+ }
137+ return nil
138+}
internal/token/token.go+19−0
@@ -0,0 +1,19 @@
1+// Package token mints executor bearer tokens.
2+package token
3+
4+import (
5+ "crypto/rand"
6+ "encoding/hex"
7+ "fmt"
8+)
9+
10+// Mint returns a 256-bit random token, hex-encoded (64 chars) — matches
11+// `head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'` from the bash
12+// prototype (dossier §2.1).
13+func Mint() (string, error) {
14+ buf := make([]byte, 32)
15+ if _, err := rand.Read(buf); err != nil {
16+ return "", fmt.Errorf("token: mint: %w", err)
17+ }
18+ return hex.EncodeToString(buf), nil
19+}
internal/token/token_test.go+25−0
@@ -0,0 +1,25 @@
1+package token
2+
3+import "testing"
4+
5+func TestMint(t *testing.T) {
6+ tok, err := Mint()
7+ if err != nil {
8+ t.Fatalf("Mint: %v", err)
9+ }
10+ if len(tok) != 64 {
11+ t.Fatalf("Mint: want 64 hex chars, got %d (%q)", len(tok), tok)
12+ }
13+ for _, c := range tok {
14+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
15+ t.Fatalf("Mint: non-hex char %q in %q", c, tok)
16+ }
17+ }
18+ tok2, err := Mint()
19+ if err != nil {
20+ t.Fatalf("Mint: %v", err)
21+ }
22+ if tok == tok2 {
23+ t.Fatalf("Mint: two calls returned the same token")
24+ }
25+}
internal/version/version.go+6−0
@@ -0,0 +1,6 @@
1+// Package version holds the CLI's build-time version string.
2+package version
3+
4+// Version is set via -ldflags at build time (see Makefile). Defaults to
5+// "dev" for `go run`/unversioned builds.
6+var Version = "dev"
main.go+12−0
@@ -0,0 +1,12 @@
1+// Command tx9 creates and manages hermes boxes (see docs/tx9-cli-design.md).
2+package main
3+
4+import (
5+ "os"
6+
7+ "github.com/davis7dotsh/tx9/internal/cli"
8+)
9+
10+func main() {
11+ os.Exit(cli.Run(os.Args, BuildContext))
12+}
"a/ops/systemd/mnt-davis\\x2dvault-tx9.automount" "b/ops/systemd/mnt-davis\\x2dvault-tx9.automount"+0−9
@@ -1,9 +0,0 @@
1−[Unit]
2−Description=Automount CIFS backup vault for tx9
3−
4−[Automount]
5−Where=/mnt/davis-vault/tx9
6−TimeoutIdleSec=600
7−
8−[Install]
9−WantedBy=multi-user.target
"a/ops/systemd/mnt-davis\\x2dvault-tx9.mount" "b/ops/systemd/mnt-davis\\x2dvault-tx9.mount"+0−10
@@ -1,10 +0,0 @@
1−[Unit]
2−Description=CIFS backup vault for tx9
3−
4−[Mount]
5−# Replace with the real CIFS share. The credentials file should contain
6−# `username=...` / `password=...` lines, mode 0600, owned by root.
7−What=//nas.example.invalid/share
8−Where=/mnt/davis-vault/tx9
9−Type=cifs
10−Options=credentials=/etc/tx9/cifs-credentials,uid=tx9,gid=tx9,file_mode=0600,dir_mode=0700,vers=3.0,_netdev
ops/systemd/tx9-backup-prune@.service+0−16
@@ -1,16 +0,0 @@
1−[Unit]
2−Description=Prune retained backups for tx9 agent box %i
3−After=tx9-backup@%i.service
4−
5−[Service]
6−Type=oneshot
7−User=tx9
8−Group=tx9
9−UMask=0077
10−NoNewPrivileges=true
11−ProtectHome=true
12−ProtectSystem=strict
13−PrivateTmp=true
14−ReadWritePaths=/opt/tx9 /var/backups/tx9 /var/lib/tx9 /etc/tx9
15−ExecStart=/opt/tx9/ops/tx9-backup-prune %i
16−TimeoutStartSec=5min
ops/systemd/tx9-backup-prune@.timer+0−10
@@ -1,10 +0,0 @@
1−[Unit]
2−Description=Scheduled backup retention prune for tx9 agent box %i
3−
4−[Timer]
5−OnCalendar=*-*-* 04:00:00
6−RandomizedDelaySec=30min
7−Persistent=true
8−
9−[Install]
10−WantedBy=timers.target
ops/systemd/tx9-backup@.service+0−18
@@ -1,18 +0,0 @@
1−[Unit]
2−Description=Encrypted backup for tx9 agent box %i
3−After=tx9-box@%i.service
4−
5−[Service]
6−Type=oneshot
7−User=tx9
8−Group=tx9
9−SupplementaryGroups=kvm
10−UMask=0077
11−NoNewPrivileges=true
12−ProtectHome=true
13−ProtectSystem=strict
14−PrivateTmp=true
15−ReadWritePaths=/opt/tx9 /var/backups/tx9 /var/lib/tx9 /etc/tx9
16−LoadCredential=backup-passphrase:/etc/tx9/backup-passphrase
17−ExecStart=/opt/tx9/ops/tx9-host backup %i
18−TimeoutStartSec=2h
ops/systemd/tx9-backup@.timer+0−10
@@ -1,10 +0,0 @@
1−[Unit]
2−Description=Scheduled encrypted backup for tx9 agent box %i
3−
4−[Timer]
5−OnCalendar=*-*-* 03:15:00
6−RandomizedDelaySec=30min
7−Persistent=true
8−
9−[Install]
10−WantedBy=timers.target
ops/systemd/tx9-box@.service+0−26
@@ -1,26 +0,0 @@
1−[Unit]
2−Description=tx9 agent box %i
3−After=network-online.target
4−Wants=network-online.target
5−
6−[Service]
7−Type=simple
8−User=tx9
9−Group=tx9
10−SupplementaryGroups=kvm
11−UMask=0077
12−NoNewPrivileges=true
13−ProtectHome=true
14−ProtectSystem=strict
15−PrivateTmp=true
16−ReadWritePaths=/opt/tx9 /var/backups/tx9 /var/lib/tx9 /etc/tx9
17−ExecStart=/opt/tx9/ops/tx9-host supervise %i
18−ExecStop=-/bin/kill -TERM $MAINPID
19−ExecStop=/opt/tx9/ops/tx9-host stop %i
20−Restart=on-failure
21−RestartSec=5s
22−TimeoutStartSec=10min
23−TimeoutStopSec=2min
24−
25−[Install]
26−WantedBy=multi-user.target
ops/systemd/tx9-health@.service+0−17
@@ -1,17 +0,0 @@
1−[Unit]
2−Description=Verify tx9 agent box %i
3−After=tx9-box@%i.service
4−
5−[Service]
6−Type=oneshot
7−User=tx9
8−Group=tx9
9−SupplementaryGroups=kvm
10−UMask=0077
11−NoNewPrivileges=true
12−ProtectHome=true
13−ProtectSystem=strict
14−PrivateTmp=true
15−ReadWritePaths=/opt/tx9 /var/backups/tx9 /var/lib/tx9 /etc/tx9
16−ExecStart=/opt/tx9/ops/tx9-host health %i
17−TimeoutStartSec=3min
ops/systemd/tx9-health@.timer+0−10
@@ -1,10 +0,0 @@
1−[Unit]
2−Description=Periodic health check for tx9 agent box %i
3−
4−[Timer]
5−OnBootSec=5min
6−OnUnitActiveSec=5min
7−Persistent=true
8−
9−[Install]
10−WantedBy=timers.target
ops/tx9-backup-prune+0−70
@@ -1,70 +0,0 @@
1−#!/usr/bin/env bash
2−# Bounds backup growth: without this, /var/backups/tx9 and any configured
3−# TX9_NAS_DIR grow unbounded forever (retention is otherwise left entirely to
4−# the operator, per docs/nexus-operations.md).
5−set -euo pipefail
6−
7−requested_name="${1:-}"
8−[[ "$requested_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || {
9− echo "usage: tx9-backup-prune <box>" >&2
10− exit 2
11−}
12−readonly requested_name
13−
14−TX9_CONFIG="${TX9_CONFIG:-/etc/tx9/tx9.conf}"
15−[[ ! -r "$TX9_CONFIG" ]] || { # shellcheck disable=SC1090
16− source "$TX9_CONFIG"
17−}
18−box_config="${TX9_BOX_CONFIG_DIR:-/etc/tx9/boxes}/${requested_name}.conf"
19−[[ ! -r "$box_config" ]] || { # shellcheck disable=SC1090
20− source "$box_config"
21−}
22−name="$requested_name"
23−
24−destination="${TX9_BACKUP_DIR:-/var/backups/tx9}"
25−# Always keep at least this many of the newest backups, regardless of age.
26−keep_count="${TX9_BACKUP_RETAIN_COUNT:-14}"
27−# Optional: additionally never keep a backup older than this many days, even
28−# if it's within the most recent TX9_BACKUP_RETAIN_COUNT. Empty = no cap.
29−keep_days="${TX9_BACKUP_RETAIN_DAYS:-}"
30−
31−[[ "$keep_count" =~ ^[0-9]+$ ]] || {
32− echo "TX9_BACKUP_RETAIN_COUNT must be a non-negative integer" >&2; exit 2;
33−}
34−[[ -z "$keep_days" || "$keep_days" =~ ^[0-9]+$ ]] || {
35− echo "TX9_BACKUP_RETAIN_DAYS must be empty or a non-negative integer" >&2; exit 2;
36−}
37−
38−prune_dir() {
39− local dir="$1" cutoff="" entries=() total index=0 base stamp keep
40− if [[ ! -d "$dir" ]]; then
41− echo "skipping prune for missing directory: $dir" >&2
42− return 0
43− fi
44− # Backup filenames are "<name>-<YYYYMMDD-HHMMSS>Z.tar.gz.gpg"; the fixed-
45− # width zero-padded timestamp sorts lexicographically in chronological
46− # order, so plain `sort` is enough — no date parsing needed for ordering.
47− mapfile -t entries < <(
48− find "$dir" -maxdepth 1 -type f -name "${name}-*.tar.gz.gpg" -printf '%f\n' 2>/dev/null | sort
49− )
50− total="${#entries[@]}"
51− [[ "$total" -gt 0 ]] || return 0
52− [[ -z "$keep_days" ]] || cutoff="$(date -u -d "-${keep_days} days" +%Y%m%d-%H%M%SZ)"
53− for base in "${entries[@]}"; do
54− index=$((index + 1))
55− keep=1
56− [[ "$((total - index))" -lt "$keep_count" ]] || keep=0
57− if [[ "$keep" == 1 && -n "$cutoff" ]]; then
58− stamp="${base#"${name}"-}"
59− stamp="${stamp%.tar.gz.gpg}"
60− [[ "$stamp" > "$cutoff" || "$stamp" == "$cutoff" ]] || keep=0
61− fi
62− [[ "$keep" == 1 ]] || {
63− echo "pruning $dir/$base"
64− rm -f -- "$dir/$base" "$dir/${base}.sha256"
65− }
66− done
67−}
68−
69−prune_dir "$destination"
70−[[ -z "${TX9_NAS_DIR:-}" ]] || prune_dir "$TX9_NAS_DIR"
ops/tx9-host+0−106
@@ -1,106 +0,0 @@
1−#!/usr/bin/env bash
2−set -euo pipefail
3−
4−requested_name="${2:-}"
5−[[ "$requested_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || {
6− echo "usage: tx9-host {start|stop|supervise|health|backup} <box>" >&2
7− exit 2
8−}
9−readonly requested_name
10−
11−TX9_CONFIG="${TX9_CONFIG:-/etc/tx9/tx9.conf}"
12−[[ ! -r "$TX9_CONFIG" ]] || { # shellcheck disable=SC1090
13− source "$TX9_CONFIG"
14−}
15−box_config="${TX9_BOX_CONFIG_DIR:-/etc/tx9/boxes}/${requested_name}.conf"
16−[[ ! -r "$box_config" ]] || { # shellcheck disable=SC1090
17− source "$box_config"
18−}
19−name="$requested_name"
20−
21−root="${TX9_ROOT:-/opt/tx9}"
22−box="$root/box"
23−[[ -x "$box" ]] || { echo "tx9 box CLI is not executable: $box" >&2; exit 1; }
24−
25−require_active() {
26− local status
27− status="$(smolvm machine status --name "$name" 2>/dev/null)" || {
28− echo "tx9 box '$name' is missing; scheduled operation will not start it" >&2
29− return 1
30− }
31− grep -q ': running' <<<"$status" || {
32− echo "tx9 box '$name' is inactive; scheduled operation will not start it" >&2
33− return 1
34− }
35−}
36−
37−case "${1:-}" in
38− start) exec "$box" start "$name" ;;
39− stop) exec "$box" stop "$name" ;;
40− supervise)
41− interval="${TX9_SUPERVISE_INTERVAL_SECONDS:-20}"
42− [[ "$interval" =~ ^[1-9][0-9]*$ && "$interval" -le 300 ]] || {
43− echo "TX9_SUPERVISE_INTERVAL_SECONDS must be 1-300" >&2; exit 2;
44− }
45− stopping=0
46− sleeper=""
47− stop_supervisor() {
48− stopping=1
49− [[ -z "$sleeper" ]] || kill "$sleeper" 2>/dev/null || true
50− }
51− trap stop_supervisor HUP INT TERM
52− "$box" start "$name"
53− while [[ "$stopping" == 0 ]]; do
54− if [[ "${TX9_SUPERVISE_ONCE:-0}" != 1 ]]; then
55− sleep "$interval" &
56− sleeper=$!
57− wait "$sleeper" || true
58− sleeper=""
59− [[ "$stopping" == 0 ]] || break
60− fi
61− [[ "$stopping" == 0 ]] || break
62− if ! smolvm machine status --name "$name" 2>/dev/null | grep -q ': running'; then
63− [[ "$stopping" == 0 ]] || break
64− echo "tx9 box '$name' stopped unexpectedly; restarting" >&2
65− "$box" start "$name"
66− fi
67− [[ "${TX9_SUPERVISE_ONCE:-0}" != 1 ]] || break
68− done
69− trap - HUP INT TERM
70− ;;
71− health) require_active; exec "$box" doctor "$name" ;;
72− backup)
73− require_active
74− destination="${TX9_BACKUP_DIR:-/var/backups/tx9}"
75− credential="${BOX_PASSPHRASE_FILE:-${CREDENTIALS_DIRECTORY:+${CREDENTIALS_DIRECTORY}/backup-passphrase}}"
76− [[ -n "$credential" && -r "$credential" ]] || {
77− echo "backup passphrase credential is unavailable" >&2; exit 1;
78− }
79− mkdir -p "$destination"
80− stamp="$(date -u +%Y%m%d-%H%M%SZ)"
81− base="${name}-${stamp}.tar.gz.gpg"
82− partial="$destination/${base}.partial"
83− final="$destination/$base"
84− checksum_partial="$destination/${base}.sha256.partial"
85− nas_partial=""
86− nas_checksum_partial=""
87− trap 'rm -f -- "$partial" "$checksum_partial" ${nas_partial:+"$nas_partial"} ${nas_checksum_partial:+"$nas_checksum_partial"}' EXIT HUP INT TERM
88− BOX_PASSPHRASE_FILE="$credential" "$box" save "$name" "$partial"
89− (cd "$destination" && sha256sum "${base}.partial" | sed "s/${base}\.partial$/${base}/") >"$checksum_partial"
90− mv "$partial" "$final"
91− mv "$checksum_partial" "$destination/${base}.sha256"
92− if [[ -n "${TX9_NAS_DIR:-}" ]]; then
93− mountpoint -q "$TX9_NAS_DIR" || {
94− echo "local backup retained, but NAS is not mounted: $TX9_NAS_DIR" >&2; exit 1;
95− }
96− nas_partial="$TX9_NAS_DIR/${base}.partial"
97− nas_checksum_partial="$TX9_NAS_DIR/${base}.sha256.partial"
98− cp "$final" "$nas_partial"
99− cp "$destination/${base}.sha256" "$nas_checksum_partial"
100− mv "$nas_partial" "$TX9_NAS_DIR/$base"
101− mv "$nas_checksum_partial" "$TX9_NAS_DIR/${base}.sha256"
102− fi
103− trap - EXIT HUP INT TERM
104− ;;
105− *) echo "usage: tx9-host {start|stop|supervise|health|backup} <box>" >&2; exit 2 ;;
106−esac
provision/provision.sh+16−0
@@ -331,9 +331,25 @@ main() { · 330 unmodified lines
331331 PATH="$NODE_PREFIX/bin:$UV_DIR:$PATH" "$OPT/bin/hb" versions || true
332332 }
333333
334+# Heavy tool installs only — no repo-owned guest assets. Lets the Docker
335+# image build cache the multi-minute layer independently of guest/ edits;
336+# a follow-up `assets` run places hb/profiles and seeds /data.
337+tools_only() {
338+ base_os
339+ make_agent
340+ install_node_uv
341+ install_claude
342+ install_codex
343+ install_hermes
344+ install_executor
345+ slim
346+ log "tool provisioning complete"
347+}
348+
334349 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
335350 case "${1:-full}" in
336351 full) main ;;
352+ tools) tools_only ;;
337353 assets) assets_only ;;
338354 *) log "unknown provisioning mode: $1"; exit 2 ;;
339355 esac
scripts/install.sh+114−0
@@ -0,0 +1,114 @@
1+#!/bin/sh
2+# tx9 installer -- served by https://tx9.davis7.sh/install (decision 10,
3+# docs/tx9-cli-design.md).
4+#
5+# curl -fsSL https://tx9.davis7.sh/install | sh
6+#
7+# Detects OS/arch, downloads the matching tx9 release binary + its
8+# checksums.txt from the latest GitHub release, verifies the checksum, and
9+# installs to ~/.local/bin/tx9 (override with TX9_INSTALL_DIR).
10+#
11+# Asset naming is the one invariant this script shares with
12+# internal/selfupdate.AssetName, the Makefile's `dist` target, and
13+# .github/workflows/release.yml: plain, uncompressed executables named
14+# tx9_<os>_<arch>, verified against a checksums.txt release asset in
15+# `sha256sum` output format ("<64-hex-digest> <filename>" per line).
16+#
17+# Deliberately plain POSIX sh + curl: no jq, no GitHub API call -- the
18+# stable /releases/latest/download/<asset> URL shape resolves straight to
19+# the current release's asset without needing to parse JSON.
20+set -eu
21+
22+REPO="davis7dotsh/tx9"
23+INSTALL_DIR="${TX9_INSTALL_DIR:-$HOME/.local/bin}"
24+
25+log() {
26+ printf 'tx9-install: %s\n' "$*" >&2
27+}
28+
29+die() {
30+ log "$*"
31+ exit 1
32+}
33+
34+need() {
35+ command -v "$1" >/dev/null 2>&1 || die "'$1' is required to install tx9"
36+}
37+
38+detect_os() {
39+ case "$(uname -s)" in
40+ Linux) echo linux ;;
41+ Darwin) echo darwin ;;
42+ *) die "unsupported OS: $(uname -s) (tx9 currently ships Linux and macOS binaries)" ;;
43+ esac
44+}
45+
46+detect_arch() {
47+ case "$(uname -m)" in
48+ x86_64 | amd64) echo amd64 ;;
49+ aarch64 | arm64) echo arm64 ;;
50+ *) die "unsupported architecture: $(uname -m) (tx9 currently ships amd64 and arm64 binaries)" ;;
51+ esac
52+}
53+
54+need curl
55+need uname
56+need mktemp
57+need mkdir
58+need chmod
59+need mv
60+need awk
61+
62+os=$(detect_os)
63+arch=$(detect_arch)
64+asset="tx9_${os}_${arch}"
65+
66+base_url="https://github.com/${REPO}/releases/latest/download"
67+asset_url="${base_url}/${asset}"
68+checksums_url="${base_url}/checksums.txt"
69+
70+tmpdir=$(mktemp -d)
71+trap 'rm -rf "$tmpdir"' EXIT INT TERM HUP
72+
73+log "downloading ${asset}..."
74+if ! curl -fsSL "$asset_url" -o "$tmpdir/$asset"; then
75+ die "download failed: $asset_url (no release published yet, or an unsupported platform)"
76+fi
77+
78+log "downloading checksums.txt..."
79+if ! curl -fsSL "$checksums_url" -o "$tmpdir/checksums.txt"; then
80+ die "download failed: $checksums_url"
81+fi
82+
83+expected=$(awk -v want="$asset" '$2 == want { print $1; found=1 } END { if (!found) exit 1 }' "$tmpdir/checksums.txt") \
84+ || die "no checksum entry for ${asset} in checksums.txt"
85+
86+if command -v sha256sum >/dev/null 2>&1; then
87+ actual=$(sha256sum "$tmpdir/$asset" | awk '{ print $1 }')
88+elif command -v shasum >/dev/null 2>&1; then
89+ actual=$(shasum -a 256 "$tmpdir/$asset" | awk '{ print $1 }')
90+else
91+ die "neither sha256sum nor shasum found; cannot verify ${asset} (install them, or download and verify manually)"
92+fi
93+
94+[ "$actual" = "$expected" ] || die "checksum mismatch for ${asset}: got ${actual}, want ${expected} (not installing)"
95+
96+mkdir -p "$INSTALL_DIR"
97+chmod 0755 "$tmpdir/$asset"
98+mv "$tmpdir/$asset" "$INSTALL_DIR/tx9"
99+
100+log "installed tx9 to ${INSTALL_DIR}/tx9"
101+
102+case ":${PATH}:" in
103+ *":${INSTALL_DIR}:"*) ;;
104+ *)
105+ log "note: ${INSTALL_DIR} is not on your PATH. Add it to your shell profile, e.g.:"
106+ log " export PATH=\"${INSTALL_DIR}:\$PATH\""
107+ ;;
108+esac
109+
110+if version=$("$INSTALL_DIR/tx9" version 2>/dev/null); then
111+ log "tx9 ${version} ready"
112+else
113+ log "installed, but 'tx9 version' did not run cleanly -- check ${INSTALL_DIR}/tx9 manually"
114+fi
tests/cli-surface.sh+0−194
@@ -1,194 +0,0 @@
1−#!/usr/bin/env bash
2−# Coverage for box subcommands that previously had zero test coverage:
3−# ls, rm, stop, start, enter, plus the _assign_port exhaustion path and
4−# control-character archive rejection (the latter lives in
5−# tests/lifecycle-smoke.sh, alongside the rest of the archive-safety suite).
6−set -euo pipefail
7−
8−# shellcheck source=tests/lib.sh
9−source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
10−
11−# --- box ls (empty registry) -------------------------------------------
12−ls_empty_repo="$tmp/ls-empty-repo"
13−make_repo "$ls_empty_repo"
14−out="$(PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/ls-empty.calls" \
15− SMOLVM_STATE_DIR="$tmp/ls-empty-state" "$ls_empty_repo/box" ls)"
16−[[ "$out" == "No hermes-box-lite boxes yet. Create one: ./box new <name>" ]]
17−
18−# --- box ls (populated registry: one running, one missing from smolvm) -
19−ls_repo="$tmp/ls-repo"
20−make_repo "$ls_repo"
21−mkdir -p "$tmp/ls-state/present-box"
22−printf 'present-box\t4790\t8650\t8650\nghost-box\t\t\t\n' >"$ls_repo/.boxes"
23−out="$(PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/ls.calls" \
24− SMOLVM_STATE_DIR="$tmp/ls-state" "$ls_repo/box" ls)"
25−grep -q '^BOX *STATE' <<<"$out"
26−grep -q '^present-box *running *http://localhost:4790 *http://localhost:8650' <<<"$out"
27−grep -q '^ghost-box *missing *— *—' <<<"$out"
28−
29−# --- box ls --all (needs the smolvm fixture's bare `machine ls`) -------
30−mkdir -p "$tmp/ls-state/other-vm"
31−out="$(PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/ls-all.calls" \
32− SMOLVM_STATE_DIR="$tmp/ls-state" "$ls_repo/box" ls --all)"
33−grep -q 'present-box' <<<"$out"
34−grep -q 'other-vm' <<<"$out"
35−
36−# --- box rm <name> --force ----------------------------------------------
37−rm_repo="$tmp/rm-repo"
38−make_repo "$rm_repo"
39−mkdir -p "$tmp/rm-state/force-box"
40−printf 'force-box\t4790\t\t\n' >"$rm_repo/.boxes"
41−rm_sentinel="$tmp/rm-force.calls"
42−out="$(PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$rm_sentinel" \
43− SMOLVM_STATE_DIR="$tmp/rm-state" "$rm_repo/box" rm force-box --force)"
44−grep -q "deleted 'force-box'" <<<"$out"
45−grep -q -- '--name force-box -f' "$rm_sentinel"
46−[[ ! -s "$rm_repo/.boxes" ]]
47−[[ ! -d "$tmp/rm-state/force-box" ]]
48−
49−# --- box rm <name> without --force and without a controlling terminal ---
50−# `[[ -r /dev/tty ]]` on Linux is a permission-bits check, not proof a
51−# controlling terminal exists, so a plain stdin redirect doesn't reach the
52−# "refusing non-interactive deletion" message here (it does on a real
53−# interactive shell). setsid genuinely detaches the terminal, so this
54−# asserts the safety property that actually matters regardless of which
55−# of the two failure modes fires: no confirmation reached, no deletion.
56−# setsid/timeout are util-linux, not on stock macOS — skip rather than fail
57−# the whole suite for a host the project otherwise supports.
58−if ! command -v setsid >/dev/null 2>&1 || ! command -v timeout >/dev/null 2>&1; then
59− echo "setsid/timeout unavailable; skipping the no-controlling-terminal box rm regression" >&2
60−else
61− rm_noforce_repo="$tmp/rm-noforce-repo"
62− make_repo "$rm_noforce_repo"
63− mkdir -p "$tmp/rm-noforce-state/no-force-box"
64− printf 'no-force-box\t\t\t\n' >"$rm_noforce_repo/.boxes"
65− noforce_sentinel="$tmp/rm-noforce.calls"
66− if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$noforce_sentinel" \
67− SMOLVM_STATE_DIR="$tmp/rm-noforce-state" \
68− timeout 10 setsid "$rm_noforce_repo/box" rm no-force-box </dev/null >/dev/null 2>&1; then
69− echo "box rm without --force unexpectedly succeeded with no controlling terminal" >&2
70− exit 1
71− fi
72− [[ ! -e "$noforce_sentinel" ]] || { echo "smolvm ran before rm confirmation" >&2; exit 1; }
73− grep -q '^no-force-box' "$rm_noforce_repo/.boxes"
74−fi
75−
76−# --- box rm <name>: typed-name confirmation, both mismatch and match ----
77−# Exercises the real /dev/tty prompt path via a pty, since `--force` and the
78−# no-tty refusal above don't reach this code at all.
79−rm_tty_repo="$tmp/rm-tty-repo"
80−make_repo "$rm_tty_repo"
81−mkdir -p "$tmp/rm-tty-state/tty-box"
82−printf 'tty-box\t\t\t\n' >"$rm_tty_repo/.boxes"
83−tty_sentinel="$tmp/rm-tty.calls"
84−PROJECT_ROOT="$PROJECT_ROOT" RM_REPO="$rm_tty_repo" RM_STATE="$tmp/rm-tty-state" \
85− RM_SENTINEL="$tty_sentinel" python3 <<'PY'
86−import os
87−import pty
88−import sys
89−import time
90−
91−project_root = os.environ["PROJECT_ROOT"]
92−repo = os.environ["RM_REPO"]
93−state = os.environ["RM_STATE"]
94−sentinel = os.environ["RM_SENTINEL"]
95−
96−
97−def run(answer):
98− pid, master_fd = pty.fork()
99− if pid == 0:
100− os.environ["PATH"] = project_root + "/tests/fixtures:" + os.environ["PATH"]
101− os.environ["SMOLVM_CALLED"] = sentinel
102− os.environ["SMOLVM_STATE_DIR"] = state
103− os.execv(repo + "/box", [repo + "/box", "rm", "tty-box"])
104− buf = b""
105− deadline = time.time() + 10
106− while b"Type the box name" not in buf and time.time() < deadline:
107− try:
108− chunk = os.read(master_fd, 4096)
109− except OSError:
110− break
111− if not chunk:
112− break
113− buf += chunk
114− os.write(master_fd, (answer + "\n").encode())
115− try:
116− while True:
117− chunk = os.read(master_fd, 4096)
118− if not chunk:
119− break
120− buf += chunk
121− except OSError:
122− pass
123− _, status = os.waitpid(pid, 0)
124− code = os.WEXITSTATUS(status) if os.WIFEXITED(status) else -1
125− return code, buf
126−
127−code, output = run("wrong-name")
128−if code != 1 or b"deletion cancelled" not in output:
129− sys.exit(f"mismatched confirmation did not refuse deletion: code={code} output={output!r}")
130−if os.path.exists(sentinel):
131− sys.exit("smolvm ran despite a confirmation mismatch")
132−
133−code, output = run("tty-box")
134−if code != 0 or b"deleted 'tty-box'" not in output:
135− sys.exit(f"matching confirmation did not delete the box: code={code} output={output!r}")
136−PY
137−[[ ! -s "$rm_tty_repo/.boxes" ]]
138−[[ ! -d "$tmp/rm-tty-state/tty-box" ]]
139−
140−# --- box stop / box start: happy path delegates to smolvm ---------------
141−stop_repo="$tmp/stop-repo"
142−make_repo "$stop_repo"
143−mkdir -p "$tmp/stop-state/stop-box"
144−stop_sentinel="$tmp/stop.calls"
145−PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$stop_sentinel" \
146− SMOLVM_STATE_DIR="$tmp/stop-state" "$stop_repo/box" stop stop-box >/dev/null
147−grep -q -- 'machine stop --name stop-box' "$stop_sentinel"
148−start_sentinel="$tmp/start.calls"
149−PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$start_sentinel" \
150− SMOLVM_STATE_DIR="$tmp/stop-state" "$stop_repo/box" start stop-box >/dev/null
151−grep -q -- 'machine start --name stop-box' "$start_sentinel"
152−
153−# --- box stop: a held per-box lock blocks a concurrent operation --------
154−lock_repo="$tmp/lock-repo"
155−make_repo "$lock_repo"
156−mkdir -p "$tmp/lock-state/locked-box" "$lock_repo/.box-locks/locked-box.lock"
157−printf '%s\n' "$$" >"$lock_repo/.box-locks/locked-box.lock/pid"
158−locked_sentinel="$tmp/locked.calls"
159−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$locked_sentinel" \
160− SMOLVM_STATE_DIR="$tmp/lock-state" "$lock_repo/box" stop locked-box >/dev/null 2>"$tmp/locked.err"; then
161− echo "box stop unexpectedly succeeded against a held lock" >&2
162− exit 1
163−fi
164−grep -q "already holds the 'locked-box' lock" "$tmp/locked.err"
165−[[ ! -e "$locked_sentinel" ]] || { echo "smolvm ran despite a held lock" >&2; exit 1; }
166−
167−# --- box enter: execs the expected smolvm command line -------------------
168−enter_repo="$tmp/enter-repo"
169−make_repo "$enter_repo"
170−mkdir -p "$tmp/enter-state/enter-box"
171−enter_sentinel="$tmp/enter.calls"
172−PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$enter_sentinel" \
173− SMOLVM_BEHAVIOR=success \
174− SMOLVM_STATE_DIR="$tmp/enter-state" "$enter_repo/box" enter enter-box >/dev/null
175−grep -q -- 'machine start --name enter-box' "$enter_sentinel"
176−grep -q -- 'machine exec -it --name enter-box -- login -f agent' "$enter_sentinel"
177−
178−# --- _assign_port: the exhaustion path dies with the documented message -
179−port_repo="$tmp/port-repo"
180−make_repo "$port_repo"
181−printf 'port-hog\t39999\t\t\n' >"$port_repo/.boxes"
182−if err="$(
183− cd "$port_repo"
184− # shellcheck disable=SC1091
185− source ./box
186− _assign_port 39999 39999 testlabel 2>&1
187−)"; then
188− echo "_assign_port unexpectedly succeeded against an exhausted range" >&2
189− exit 1
190−else
191− grep -q 'no free host port in 39999-39999' <<<"$err"
192−fi
193−
194−echo "cli-surface checks passed"
tests/fixtures/smolvm+0−105
@@ -1,105 +0,0 @@
1−#!/usr/bin/env bash
2−set -euo pipefail
3−
4−state="${SMOLVM_STATE_DIR:?SMOLVM_STATE_DIR must be set}"
5−sentinel="${SMOLVM_CALLED:?SMOLVM_CALLED must point to a sentinel}"
6−behavior="${SMOLVM_BEHAVIOR:-fail-exec}"
7−mkdir -p "$state"
8−printf '%q ' "$@" >>"$sentinel"
9−printf '\n' >>"$sentinel"
10−
11−name_from_args() {
12− local previous="" value
13− for value in "$@"; do
14− if [[ "$previous" == --name ]]; then printf '%s' "$value"; return; fi
15− previous="$value"
16− done
17− return 1
18−}
19−
20−[[ "${1:-}" == machine ]] || exit 2
21−case "${2:-}" in
22− ls)
23− printf 'NAME\tSTATUS\n'
24− for entry in "$state"/*/; do
25− [[ -d "$entry" ]] || continue
26− printf '%s\trunning\n' "$(basename "$entry")"
27− done
28− ;;
29− status)
30− name="$(name_from_args "$@")"
31− [[ "$behavior" != delete-status-fail ]] || exit 42
32− [[ -d "$state/$name" ]] || exit 1
33− if [[ "$behavior" == inactive || "$behavior" == inactive-import-post-fail ]]; then
34− printf "Machine '%s': stopped\n" "$name"
35− else
36− printf "Machine '%s': running\n" "$name"
37− fi
38− ;;
39− create)
40− name="$(name_from_args "$@")"
41− [[ ! -d "$state/$name" ]] || exit 1
42− mkdir -p "$state/$name"
43− [[ "$behavior" != create-race ]] || exit 1
44− if [[ "$behavior" == signal-create-success ]]; then kill -TERM "$PPID"; fi
45− ;;
46− start | stop)
47− name="$(name_from_args "$@")"
48− # Project syntax is `smolvm machine start`; the action is argument two.
49− if [[ "${2:-}" == start && "$behavior" == start-delete-fail ]]; then exit 42; fi
50− [[ -d "$state/$name" ]]
51− ;;
52− delete)
53− name="$(name_from_args "$@")"
54− [[ "$behavior" != delete-fail && "$behavior" != delete-status-fail && "$behavior" != start-delete-fail ]] || exit 42
55− rm -rf "${state:?}/${name:?}"
56− ;;
57− exec)
58− name="$(name_from_args "$@")"
59− if [[ " $* " == *" -i "* ]]; then cat >/dev/null; fi
60− if [[ "$behavior" == fail-exec ]]; then exit 42; fi
61− if [[ "$behavior" == block-provision && "$*" == *"provision/provision.sh 'full'"* ]]; then
62− kill -TERM "$PPID"
63− sleep 1
64− exit 143
65− fi
66− if [[ "$behavior" == registry-contention && "$*" == *"provision/provision.sh 'full'"* ]]; then
67− mkdir -p "${SMOLVM_REGISTRY_LOCK_DIR:?missing registry lock dir}"
68− printf '%s\n' "${SMOLVM_REGISTRY_LOCK_OWNER_PID:?missing lock owner pid}" >"$SMOLVM_REGISTRY_LOCK_DIR/pid"
69− exit 42
70− fi
71− if [[ "$behavior" == stale-registry-lock && "$*" == *"provision/provision.sh 'full'"* ]]; then
72− mkdir -p "${SMOLVM_REGISTRY_LOCK_DIR:?missing registry lock dir}"
73− printf '99999999\n' >"$SMOLVM_REGISTRY_LOCK_DIR/pid"
74− exit 42
75− fi
76− if [[ ( "$behavior" == delete-fail || "$behavior" == delete-status-fail ) && "$*" == *"provision/provision.sh 'full'"* ]]; then exit 42; fi
77− if [[ "$behavior" == restore-tar-fail && "$*" == *"archive=\$1"* && "$*" == *"tar xzf"* ]]; then
78− exit 42
79− fi
80− if [[ "$behavior" == signal-import && "$*" == *"hermes-state import-zip"* ]]; then
81− kill -TERM "$PPID"
82− sleep 1
83− exit 143
84− fi
85− if [[ ( "$behavior" == import-post-fail || "$behavior" == inactive-import-post-fail ) && "$*" == *" hb verify-state"* ]]; then exit 42; fi
86− if [[ "$*" == *" hb is-paused"* ]]; then [[ -e "$state/$name/paused" ]]; exit; fi
87− if [[ "$*" == *" hb pause"* ]]; then
88− touch "$state/$name/paused"
89− [[ "$behavior" != pause-fail ]] || exit 42
90− fi
91− if [[ "$*" == *" hb gateway-disable"* ]]; then touch "$state/$name/gateway-disabled"; fi
92− if [[ "$*" == *" hb resume"* ]]; then
93− rm -f "$state/$name/paused"
94− [[ -e "$state/$name/gateway-disabled" ]] || touch "$state/$name/gateway-restarted"
95− fi
96− if [[ "$*" == *" hb executor-token"* ]]; then printf 'fixture-token\n'; fi
97− ;;
98− cp)
99− if [[ "$behavior" == save-cp-fail ]]; then exit 42; fi
100− if [[ -n "${SMOLVM_ARCHIVE_SOURCE:-}" && "${3:-}" == *:* ]]; then
101− cp "$SMOLVM_ARCHIVE_SOURCE" "${4:?missing copy destination}"
102− fi
103− ;;
104− *) exit 2 ;;
105−esac
tests/lib.sh+3−3
@@ -1,7 +1,7 @@
11 #!/usr/bin/env bash
22 # Shared test harness: a fresh $tmp per file, cleaned up on exit or signal
3−# using the same signal-to-exit-code mapping box itself uses (129/130/143),
4−# plus make_repo() and the wait_for_* polling helpers used across the suite.
3+# (129/130/143), plus make_repo() and the wait_for_* polling helpers used
4+# across the suite.
55 # Source this after `set -euo pipefail`; do not execute it directly.
66
77 PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -57,6 +57,6 @@ wait_for_pattern() { · 49 unmodified lines
5757 make_repo() {
5858 local dest="$1"
5959 mkdir -p "$dest/backups"
60− cp "$PROJECT_ROOT/box" "$PROJECT_ROOT/box.env" "$dest/"
60+ cp "$PROJECT_ROOT/box.env" "$dest/"
6161 cp -R "$PROJECT_ROOT/guest" "$PROJECT_ROOT/provision" "$dest/"
6262 }
tests/lifecycle-smoke.sh+0−258
@@ -1,258 +0,0 @@
1−#!/usr/bin/env bash
2−set -euo pipefail
3−
4−# shellcheck source=tests/lib.sh
5−source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
6−
7−mkdir -p "$tmp/valid/home/agent/workspace" "$tmp/invalid/not-home" "$tmp/gnupg" "$tmp/host-tmp"
8−chmod 0700 "$tmp/gnupg"
9−printf 'portable state\n' >"$tmp/valid/home/agent/workspace/probe.txt"
10−ln -s probe.txt "$tmp/valid/home/agent/workspace/probe-link"
11−ln "$tmp/valid/home/agent/workspace/probe.txt" "$tmp/valid/home/agent/workspace/probe-hard"
12−printf 'invalid state\n' >"$tmp/invalid/not-home/probe.txt"
13−tar czf "$tmp/valid.tgz" -C "$tmp/valid" .
14−tar czf "$tmp/invalid.tgz" -C "$tmp/invalid" .
15−python3 - "$tmp/absolute-symlink.tgz" "$tmp/traversal-symlink.tgz" \
16− "$tmp/invalid-hardlink.tgz" "$tmp/special-file.tgz" "$tmp/home-link.tgz" \
17− "$tmp/cycle-link.tgz" "$tmp/pivot-link.tgz" "$tmp/root-symlink.tgz" \
18− "$tmp/root-hardlink.tgz" "$tmp/linked-parent-alias.tgz" \
19− "$tmp/linked-parent-regular.tgz" "$tmp/control-char-name.tgz" \
20− "$tmp/control-char-link.tgz" <<'PY'
21−import io
22−import sys
23−import tarfile
24−
25−def directory(name):
26− info = tarfile.TarInfo(name)
27− info.type = tarfile.DIRTYPE
28− info.mode = 0o700
29− return info
30−
31−with tarfile.open(sys.argv[1], "w:gz") as archive:
32− archive.addfile(directory("home"))
33− archive.addfile(directory("home/agent"))
34− link = tarfile.TarInfo("home/agent/workspace")
35− link.type = tarfile.SYMTYPE
36− link.linkname = "/tmp/escape"
37− archive.addfile(link)
38−
39−with tarfile.open(sys.argv[2], "w:gz") as archive:
40− archive.addfile(directory("home"))
41− archive.addfile(directory("home/agent"))
42− link = tarfile.TarInfo("home/escape-link")
43− link.type = tarfile.SYMTYPE
44− link.linkname = "../../"
45− archive.addfile(link)
46−
47−with tarfile.open(sys.argv[3], "w:gz") as archive:
48− archive.addfile(directory("home"))
49− archive.addfile(directory("home/agent"))
50− link = tarfile.TarInfo("home/agent/workspace")
51− link.type = tarfile.LNKTYPE
52− link.linkname = "../../escape"
53− archive.addfile(link)
54−
55−with tarfile.open(sys.argv[4], "w:gz") as archive:
56− archive.addfile(directory("home"))
57− archive.addfile(directory("home/agent"))
58− fifo = tarfile.TarInfo("home/agent/runtime.fifo")
59− fifo.type = tarfile.FIFOTYPE
60− archive.addfile(fifo)
61−
62−with tarfile.open(sys.argv[5], "w:gz") as archive:
63− archive.addfile(directory("home"))
64− link = tarfile.TarInfo("home/agent")
65− link.type = tarfile.SYMTYPE
66− link.linkname = "/"
67− archive.addfile(link)
68−
69−with tarfile.open(sys.argv[6], "w:gz") as archive:
70− archive.addfile(directory("home"))
71− archive.addfile(directory("home/agent"))
72− first = tarfile.TarInfo("home/agent/first")
73− first.type = tarfile.SYMTYPE
74− first.linkname = "second"
75− archive.addfile(first)
76− second = tarfile.TarInfo("home/agent/second")
77− second.type = tarfile.SYMTYPE
78− second.linkname = "first"
79− archive.addfile(second)
80−
81−with tarfile.open(sys.argv[7], "w:gz") as archive:
82− archive.addfile(directory("."))
83− archive.addfile(directory("home"))
84− archive.addfile(directory("home/agent"))
85− victim_data = b"victim\n"
86− victim = tarfile.TarInfo("home/agent/victim")
87− victim.size = len(victim_data)
88− archive.addfile(victim, io.BytesIO(victim_data))
89− up = tarfile.TarInfo("home/agent/up")
90− up.type = tarfile.SYMTYPE
91− up.linkname = "../.."
92− archive.addfile(up)
93− pivot = tarfile.TarInfo("home/agent/x")
94− pivot.type = tarfile.SYMTYPE
95− pivot.linkname = "up/../victim"
96− archive.addfile(pivot)
97−
98−with tarfile.open(sys.argv[8], "w:gz") as archive:
99− root = tarfile.TarInfo("./")
100− root.type = tarfile.SYMTYPE
101− root.linkname = "home"
102− archive.addfile(root)
103− archive.addfile(directory("home"))
104− archive.addfile(directory("home/agent"))
105−
106−with tarfile.open(sys.argv[9], "w:gz") as archive:
107− root = tarfile.TarInfo(".")
108− root.type = tarfile.LNKTYPE
109− root.linkname = "home/agent"
110− archive.addfile(root)
111− archive.addfile(directory("home"))
112− archive.addfile(directory("home/agent"))
113−
114−with tarfile.open(sys.argv[10], "w:gz") as archive:
115− archive.addfile(directory("."))
116− archive.addfile(directory("home"))
117− archive.addfile(directory("home/agent"))
118− up = tarfile.TarInfo("home/agent/up")
119− up.type = tarfile.SYMTYPE
120− up.linkname = "../.."
121− archive.addfile(up)
122− alias = tarfile.TarInfo("home/agent/up/home")
123− alias.type = tarfile.SYMTYPE
124− alias.linkname = "/tmp/linked-parent-escape"
125− archive.addfile(alias)
126−
127−with tarfile.open(sys.argv[11], "w:gz") as archive:
128− archive.addfile(directory("."))
129− archive.addfile(directory("home"))
130− archive.addfile(directory("home/agent"))
131− up = tarfile.TarInfo("home/agent/up")
132− up.type = tarfile.SYMTYPE
133− up.linkname = "../.."
134− archive.addfile(up)
135− payload = b"must not extract through link\n"
136− child = tarfile.TarInfo("home/agent/up/owned")
137− child.size = len(payload)
138− archive.addfile(child, io.BytesIO(payload))
139−
140−with tarfile.open(sys.argv[12], "w:gz") as archive:
141− archive.addfile(directory("home"))
142− archive.addfile(directory("home/agent"))
143− bad = tarfile.TarInfo("home/agent/bad\x01name")
144− payload = b"control character in member name\n"
145− bad.size = len(payload)
146− archive.addfile(bad, io.BytesIO(payload))
147−
148−with tarfile.open(sys.argv[13], "w:gz") as archive:
149− archive.addfile(directory("home"))
150− archive.addfile(directory("home/agent"))
151− link = tarfile.TarInfo("home/agent/bad-link")
152− link.type = tarfile.SYMTYPE
153− link.linkname = "bad\x01target"
154− archive.addfile(link)
155−PY
156−
157−(
158− # shellcheck disable=SC1090
159− source "$PROJECT_ROOT/box"
160− _validate_name valid-box_1.2
161− _validate_archive "$tmp/valid.tgz"
162− if _validate_archive "$tmp/invalid.tgz"; then
163− echo "archive without durable home passed validation" >&2
164− exit 1
165− fi
166− for unsafe in "$tmp/absolute-symlink.tgz" "$tmp/traversal-symlink.tgz" \
167− "$tmp/invalid-hardlink.tgz" "$tmp/special-file.tgz" "$tmp/home-link.tgz" \
168− "$tmp/cycle-link.tgz" "$tmp/pivot-link.tgz" "$tmp/root-symlink.tgz" \
169− "$tmp/root-hardlink.tgz" "$tmp/linked-parent-alias.tgz" \
170− "$tmp/linked-parent-regular.tgz" "$tmp/control-char-name.tgz" \
171− "$tmp/control-char-link.tgz"; do
172− if _validate_archive "$unsafe"; then
173− echo "linked archive passed validation: $unsafe" >&2
174− exit 1
175− fi
176− done
177−)
178−
179−if (
180− # shellcheck disable=SC1090
181− source "$PROJECT_ROOT/box"
182− _validate_name '../unsafe'
183−); then
184− echo "unsafe box name passed validation" >&2
185− exit 1
186−fi
187−
188−export GNUPGHOME="$tmp/gnupg"
189−export BOX_PASSPHRASE='lifecycle-smoke-passphrase'
190−gpg --batch --yes --pinentry-mode loopback --passphrase "$BOX_PASSPHRASE" \
191− --symmetric --cipher-algo AES256 -o "$tmp/valid.tar.gz.gpg" "$tmp/valid.tgz"
192−gpg --batch --yes --pinentry-mode loopback --passphrase "$BOX_PASSPHRASE" \
193− --symmetric --cipher-algo AES256 -o "$tmp/invalid.tar.gz.gpg" "$tmp/invalid.tgz"
194−for unsafe in absolute-symlink traversal-symlink invalid-hardlink special-file home-link cycle-link \
195− pivot-link root-symlink root-hardlink linked-parent-alias linked-parent-regular \
196− control-char-name control-char-link; do
197− gpg --batch --yes --pinentry-mode loopback --passphrase "$BOX_PASSPHRASE" \
198− --symmetric --cipher-algo AES256 -o "$tmp/${unsafe}.tar.gz.gpg" "$tmp/${unsafe}.tgz"
199−done
200−
201−TMPDIR="$tmp/host-tmp" "$PROJECT_ROOT/box" extract "$tmp/valid.tar.gz.gpg" "$tmp/extracted" >/dev/null
202−cmp "$tmp/valid/home/agent/workspace/probe.txt" "$tmp/extracted/home/agent/workspace/probe.txt"
203−[[ -L "$tmp/extracted/home/agent/workspace/probe-link" ]]
204−[[ "$(readlink "$tmp/extracted/home/agent/workspace/probe-link")" == probe.txt ]]
205−cmp "$tmp/extracted/home/agent/workspace/probe-link" "$tmp/extracted/home/agent/workspace/probe.txt"
206−python3 - "$tmp/extracted/home/agent/workspace/probe.txt" \
207− "$tmp/extracted/home/agent/workspace/probe-hard" <<'PY'
208−import os
209−import sys
210−assert os.stat(sys.argv[1]).st_ino == os.stat(sys.argv[2]).st_ino
211−PY
212−[[ -z "$(find "$tmp/host-tmp" -mindepth 1 -maxdepth 1 -print -quit)" ]]
213−
214−for unsafe in absolute-symlink traversal-symlink invalid-hardlink special-file home-link cycle-link \
215− pivot-link root-symlink root-hardlink linked-parent-alias linked-parent-regular \
216− control-char-name control-char-link; do
217− unsafe_sentinel="$tmp/${unsafe}-smolvm-called"
218− if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$unsafe_sentinel" \
219− SMOLVM_STATE_DIR="$tmp/smolvm-state" TMPDIR="$tmp/host-tmp" \
220− "$PROJECT_ROOT/box" load "$tmp/${unsafe}.tar.gz.gpg" "${unsafe}-destination" >/dev/null 2>&1; then
221− echo "$unsafe archive unexpectedly loaded" >&2
222− exit 1
223− fi
224− [[ ! -e "$unsafe_sentinel" ]] || { echo "VM command ran before $unsafe validation" >&2; exit 1; }
225−done
226−
227−sentinel="$tmp/smolvm-called"
228−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$sentinel" \
229− SMOLVM_STATE_DIR="$tmp/smolvm-state" \
230− TMPDIR="$tmp/host-tmp" "$PROJECT_ROOT/box" load "$tmp/invalid.tar.gz.gpg" invalid-destination >/dev/null 2>&1; then
231− echo "invalid archive unexpectedly loaded" >&2
232− exit 1
233−fi
234−[[ ! -e "$sentinel" ]] || { echo "VM command ran before archive validation" >&2; exit 1; }
235−[[ -z "$(find "$tmp/host-tmp" -mindepth 1 -maxdepth 1 -print -quit)" ]]
236−
237−make_repo "$tmp/repo"
238−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$sentinel" \
239− SMOLVM_STATE_DIR="$tmp/smolvm-state" "$tmp/repo/box" new rollback-probe >/dev/null 2>&1; then
240− echo "mock provisioning failure unexpectedly succeeded" >&2
241− exit 1
242−fi
243−[[ ! -d "$tmp/smolvm-state/rollback-probe" ]] || { echo "failed box was not deleted" >&2; exit 1; }
244−[[ ! -s "$tmp/repo/.boxes" ]] || { echo "failed box remained registered" >&2; exit 1; }
245−[[ -z "$(find "$tmp/repo/.box-locks" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]] || {
246− echo "operation lock survived process exit" >&2
247− exit 1
248−}
249−
250−if BOX_PASSPHRASE=wrong TMPDIR="$tmp/host-tmp" \
251− "$PROJECT_ROOT/box" extract "$tmp/valid.tar.gz.gpg" "$tmp/wrong-pass" >/dev/null 2>&1; then
252− echo "wrong passphrase unexpectedly succeeded" >&2
253− exit 1
254−fi
255−[[ ! -e "$tmp/wrong-pass" ]]
256−[[ -z "$(find "$tmp/host-tmp" -mindepth 1 -maxdepth 1 -print -quit)" ]]
257−
258−echo "lifecycle smoke checks passed"
tests/regressions-box-core.sh+0−90
@@ -1,90 +0,0 @@
1−#!/usr/bin/env bash
2−# shellcheck disable=SC2016,SC2030,SC2031,SC2329
3−set -euo pipefail
4−
5−# shellcheck source=tests/lib.sh
6−source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
7−
8−# Root-run installer state is recursively returned to the agent and made
9−# writable, including nested runtime directories created during full repair.
10−hermes_fixture="$tmp/installer-hermes-home"
11−mkdir -p "$hermes_fixture/sessions/deep" "$hermes_fixture/cron" "$hermes_fixture/logs"
12−printf 'session\n' >"$hermes_fixture/sessions/deep/history.json"
13−printf 'job\n' >"$hermes_fixture/cron/jobs.json"
14−chmod 0500 "$hermes_fixture/sessions/deep"
15−chmod 0400 "$hermes_fixture/sessions/deep/history.json" "$hermes_fixture/cron/jobs.json"
16−(
17− # shellcheck disable=SC1090
18− source "$PROJECT_ROOT/provision/provision.sh"
19− HERMES_HOME="$hermes_fixture"
20− chown() {
21− [[ "$1" == -R && "$2" == agent:agent && "$3" == "$hermes_fixture" ]]
22− printf 'recursive agent ownership\n' >"$tmp/hermes-chown.called"
23− }
24− own_hermes_home
25−)
26−[[ -s "$tmp/hermes-chown.called" ]]
27−[[ -w "$hermes_fixture/sessions/deep/history.json" ]]
28−[[ -w "$hermes_fixture/cron/jobs.json" ]]
29−[[ -w "$hermes_fixture/sessions/deep" ]]
30−
31−# Restore staging grants non-owners traversal but not top-level mutation; only
32−# the nested agent home models owner-writable imported state.
33−permission_stage="$tmp/restore-permission-model"
34−mkdir -p "$permission_stage/home/agent"
35−chmod 0711 "$permission_stage" "$permission_stage/home"
36−chmod 0700 "$permission_stage/home/agent"
37−python3 - "$permission_stage" <<'PY'
38−import os
39−import stat
40−import sys
41−from pathlib import Path
42−
43−stage = Path(sys.argv[1])
44−home = stage / "home"
45−agent = home / "agent"
46−for envelope in (stage, home):
47− mode = stat.S_IMODE(envelope.stat().st_mode)
48− assert mode == 0o711
49− assert mode & 0o001
50− assert not mode & 0o002
51−assert stat.S_IMODE(agent.stat().st_mode) == 0o700
52−assert os.access(agent, os.W_OK)
53−PY
54−
55−# Missing legacy resource settings use the documented production defaults.
56−(
57− # shellcheck disable=SC1090
58− source "$PROJECT_ROOT/box"
59− unset BOX_CPUS BOX_MEM_MIB BOX_OVERLAY_GIB BOX_STORAGE_GIB
60− _validate_resources
61− [[ "$BOX_CPUS" == 4 && "$BOX_MEM_MIB" == 8192 && "$BOX_OVERLAY_GIB" == 64 && -z "$BOX_STORAGE_GIB" ]]
62−)
63−
64−# Guest bundle verification is independent of the caller's repository and
65−# rejects incremental bundles whose prerequisite objects are unavailable.
66−bundle_repo="$tmp/bundle-source"
67−mkdir -p "$bundle_repo" "$tmp/bundle-nonrepo" "$tmp/bundle-verify-tmp"
68−git -C "$bundle_repo" init -q
69−printf 'base\n' >"$bundle_repo/state.txt"
70−git -C "$bundle_repo" add state.txt
71−git -C "$bundle_repo" -c user.name=Fixture -c user.email=fixture@example.invalid commit -q -m base
72−base_commit="$(git -C "$bundle_repo" rev-parse HEAD)"
73−git -C "$bundle_repo" bundle create "$tmp/complete.bundle" --all
74−printf 'next\n' >>"$bundle_repo/state.txt"
75−git -C "$bundle_repo" add state.txt
76−git -C "$bundle_repo" -c user.name=Fixture -c user.email=fixture@example.invalid commit -q -m next
77−git -C "$bundle_repo" bundle create "$tmp/incremental.bundle" HEAD "^$base_commit"
78−(
79− cd "$tmp/bundle-nonrepo"
80− # shellcheck disable=SC1090
81− source "$PROJECT_ROOT/provision/provision.sh"
82− TMPDIR="$tmp/bundle-verify-tmp" verify_self_contained_bundle "$tmp/complete.bundle"
83− if TMPDIR="$tmp/bundle-verify-tmp" verify_self_contained_bundle "$tmp/incremental.bundle"; then
84− echo "incremental bundle passed self-contained verification" >&2
85− exit 1
86− fi
87−)
88−[[ -z "$(find "$tmp/bundle-verify-tmp" -mindepth 1 -maxdepth 1 -print -quit)" ]]
89−
90−echo "box-core regression checks passed"
tests/regressions-doctor-mcp.sh+0−357
@@ -1,357 +0,0 @@
1−#!/usr/bin/env bash
2−# shellcheck disable=SC2016,SC2030,SC2031,SC2329
3−set -euo pipefail
4−
5−# shellcheck source=tests/lib.sh
6−source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
7−
8−start_mcp_server() {
9− local status="$1" label="$2" mode="${3:-valid}" bind_host="${4:-127.0.0.1}" port_file delete_file
10− port_file="$tmp/$label.port"
11− delete_file="$tmp/$label.delete"
12− python3 "$PROJECT_ROOT/tests/fixtures/mcp-server.py" "$port_file" "$status" "$delete_file" "$mode" "$bind_host" &
13− MCP_PID=$!
14− pids+=("$MCP_PID")
15− wait_for_file "$port_file"
16− MCP_PORT="$(cat "$port_file")"
17− MCP_DELETE_FILE="$delete_file"
18−}
19−
20−# Protocol-correct MCP initialize requires exact 200 plus a session header and
21−# closes the created session. Other HTTP statuses are unhealthy.
22−(
23− # shellcheck disable=SC1090
24− source "$PROJECT_ROOT/box"
25− REG="$tmp/empty-registry"
26− touch "$REG"
27−
28− (
29− doctor_calls="$tmp/doctor.calls"
30− mock_quiesced=0
31− mock_gateway_disabled=0
32− have() { [[ "$1" == awk || "$1" == smolvm ]]; }
33− _acquire_lock() { :; }
34− _machine_exists() { :; }
35− _guest_hb() {
36− printf '%s\n' "$2" >>"$doctor_calls"
37− if [[ "$2" == is-paused ]]; then [[ "$mock_quiesced" == 1 ]]; return; fi
38− if [[ "$2" == gateway-is-disabled ]]; then [[ "$mock_gateway_disabled" == 1 ]]; return; fi
39− [[ "$2" != executor-token ]] || printf 'fixture-token\n'
40− }
41− mcp_attempts=0
42− sleep() { :; }
43− _mcp_initialize() {
44− printf 'host-mcp\n' >>"$doctor_calls"
45− mcp_attempts=$((mcp_attempts + 1))
46− [[ "$mcp_attempts" -ge 3 ]]
47− }
48−
49− doctor_output="$(cmd_doctor no-host-port)"
50− grep -q 'host Executor MCP: skipped (no exposed host port)' <<<"$doctor_output"
51− [[ "$(cat "$doctor_calls")" == $'is-paused\ndoctor' ]]
52−
53− printf 'doctor-host\t4899\n' >"$REG"
54− if (cmd_doctor doctor-host >/dev/null 2>&1); then
55− echo "exposed-port doctor passed without host health dependencies" >&2
56− exit 1
57− fi
58− [[ "$(cat "$doctor_calls")" == $'is-paused\ndoctor\nis-paused\ndoctor' ]]
59−
60− : >"$doctor_calls"
61− have() { :; }
62− cmd_doctor doctor-host >/dev/null
63− [[ "$(cat "$doctor_calls")" == $'is-paused\ndoctor\nexecutor-token\nhost-mcp\nhost-mcp\nhost-mcp' ]]
64−
65− : >"$doctor_calls"
66− mock_quiesced=1
67− cmd_doctor doctor-host >/dev/null
68− [[ "$(cat "$doctor_calls")" == $'is-paused\ndoctor' ]]
69− mock_quiesced=0
70−
71− : >"$doctor_calls"
72− printf 'api-host\t\t8650\t8650\n' >"$REG"
73− _hermes_api_healthy() { printf 'host-api\n' >>"$doctor_calls"; }
74− api_output="$(cmd_doctor api-host)"
75− grep -q 'host Hermes API: healthy' <<<"$api_output"
76− [[ "$(cat "$doctor_calls")" == $'is-paused\ngateway-is-disabled\ndoctor\nhost-api' ]]
77−
78− : >"$doctor_calls"
79− mock_gateway_disabled=1
80− _hermes_api_healthy() { touch "$tmp/disabled-api-probed"; return 1; }
81− api_output="$(cmd_doctor api-host)"
82− grep -q 'host Hermes API: intentionally disabled by gateway policy' <<<"$api_output"
83− [[ "$(cat "$doctor_calls")" == $'is-paused\ngateway-is-disabled\ndoctor' ]]
84− [[ ! -e "$tmp/disabled-api-probed" ]]
85− mock_gateway_disabled=0
86−
87− : >"$doctor_calls"
88− : >"$REG"
89− WIRE_EXECUTOR_MCP=0 INSTALL_EXECUTOR=0 cmd_doctor disabled-executor >/dev/null
90− [[ "$(cat "$doctor_calls")" == $'is-paused\ndoctor' ]]
91− )
92−
93− BOX_BROWSER=true _open_url 'http://localhost:1/?_token=not-printed'
94− if BOX_BROWSER=definitely-not-a-browser _open_url 'http://localhost:1/?_token=not-printed'; then
95− echo "missing BOX_BROWSER command unexpectedly succeeded" >&2
96− exit 1
97− fi
98− uname() { printf 'Linux\n'; }
99− xdg-open() { return 1; }
100− gio() { [[ "$1" == open && "$2" == 'https://fixture.invalid/secret' ]]; }
101− _open_url 'https://fixture.invalid/secret'
102− uname() { printf 'Darwin\n'; }
103− open() { [[ "$1" == 'https://fixture.invalid/secret' ]]; }
104− _open_url 'https://fixture.invalid/secret'
105−
106− start_mcp_server 200 healthy
107− _hermes_api_healthy "$MCP_PORT"
108− _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token
109− wait_for_pattern initialized "$MCP_DELETE_FILE"
110− wait_for_pattern deleted "$MCP_DELETE_FILE"
111− _wait_mcp_ready "http://127.0.0.1:$MCP_PORT/mcp" fixture-token
112− if _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" wrong-token; then
113− echo "invalid bearer token passed MCP health" >&2
114− exit 1
115− fi
116− malformed_code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
117− -H 'Authorization: Bearer fixture-token' -H 'Content-Type: application/json' \
118− -H 'Accept: application/json,text/event-stream' --data '{not-json' \
119− "http://127.0.0.1:$MCP_PORT/mcp")"
120− [[ "$malformed_code" == 400 ]]
121− for invalid_request in \
122− '[]' \
123− '{"jsonrpc":"2.0","id":1,"method":"initialize","params":[]}' \
124− '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":[]}}'; do
125− invalid_code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
126− -H 'Authorization: Bearer fixture-token' -H 'Content-Type: application/json' \
127− -H 'Accept: application/json,text/event-stream' --data "$invalid_request" \
128− "http://127.0.0.1:$MCP_PORT/mcp")"
129− [[ "$invalid_code" == 400 ]]
130− done
131− invalid_length_code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
132− -H 'Authorization: Bearer fixture-token' -H 'Content-Type: application/json' \
133− -H 'Accept: application/json,text/event-stream' -H 'Content-Length: invalid' \
134− --data '{}' "http://127.0.0.1:$MCP_PORT/mcp")"
135− [[ "$invalid_length_code" == 400 ]]
136−
137− guest_token_dir="$tmp/guest-token/.executor/server-control"
138− mkdir -p "$guest_token_dir"
139− printf '{"token":"fixture-token"}\n' >"$guest_token_dir/auth.json"
140− # shellcheck disable=SC1090
141− source "$PROJECT_ROOT/guest/hb"
142− TOKEN_FILE="$guest_token_dir/auth.json"
143− EXECUTOR_PORT="$MCP_PORT"
144− _mcp_auth_works
145− printf '{"token":"wrong-token"}\n' >"$guest_token_dir/auth.json"
146− if _mcp_auth_works; then
147− echo "guest MCP health accepted an invalid bearer token" >&2
148− exit 1
149− fi
150− if _port_free "$MCP_PORT"; then
151− echo "occupied TCP port was reported free" >&2
152− exit 1
153− fi
154− kill "$MCP_PID"
155− wait "$MCP_PID" 2>/dev/null || true
156−
157− for unhealthy_status in 401 404 500; do
158− start_mcp_server "$unhealthy_status" "api-$unhealthy_status"
159− if _hermes_api_healthy "$MCP_PORT"; then
160− echo "Hermes API HTTP $unhealthy_status passed health" >&2
161− exit 1
162− fi
163− kill "$MCP_PID"
164− wait "$MCP_PID" 2>/dev/null || true
165− done
166− start_mcp_server 200 api-malformed health-malformed
167− if _hermes_api_healthy "$MCP_PORT"; then
168− echo "malformed Hermes API health JSON passed" >&2
169− exit 1
170− fi
171− kill "$MCP_PID"
172− wait "$MCP_PID" 2>/dev/null || true
173−
174− if python3 -c 'import socket; s = socket.socket(socket.AF_INET6); s.bind(("::1", 0)); s.close()'; then
175− start_mcp_server 200 ipv6-only valid ::1
176− if _port_free "$MCP_PORT"; then
177− echo "IPv6-only occupied TCP port was reported free" >&2
178− exit 1
179− fi
180− kill "$MCP_PID"
181− wait "$MCP_PID" 2>/dev/null || true
182− else
183− echo "IPv6 loopback unavailable; skipping IPv6 occupancy regression" >&2
184− fi
185−
186− start_mcp_server 200 malformed-json malformed-json
187− if _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token; then
188− echo "malformed initialize JSON passed MCP health" >&2
189− exit 1
190− fi
191− kill "$MCP_PID"
192− wait "$MCP_PID" 2>/dev/null || true
193−
194− start_mcp_server 200 wrong-version wrong-version
195− if _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token; then
196− echo "wrong negotiated MCP version passed health" >&2
197− exit 1
198− fi
199− kill "$MCP_PID"
200− wait "$MCP_PID" 2>/dev/null || true
201−
202− start_mcp_server 200 wrong-content-type wrong-content-type
203− if _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token; then
204− echo "wrong initialize Content-Type passed MCP health" >&2
205− exit 1
206− fi
207− kill "$MCP_PID"
208− wait "$MCP_PID" 2>/dev/null || true
209−
210− start_mcp_server 200 event-stream event-stream
211− _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token
212− kill "$MCP_PID"
213− wait "$MCP_PID" 2>/dev/null || true
214−
215− start_mcp_server 404 missing
216− if _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token; then
217− echo "HTTP 404 passed MCP health" >&2
218− exit 1
219− fi
220− kill "$MCP_PID"
221− wait "$MCP_PID" 2>/dev/null || true
222−
223− start_mcp_server 500 broken
224− if _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token; then
225− echo "HTTP 500 passed MCP health" >&2
226− exit 1
227− fi
228− kill "$MCP_PID"
229− wait "$MCP_PID" 2>/dev/null || true
230−
231− free_port="$MCP_PORT"
232− for ((attempt = 0; attempt < 100; attempt++)); do
233− _port_free "$free_port" && break
234− sleep 0.02
235− done
236− _port_free "$free_port"
237−
238− ready_attempts=0
239− opener_attempt=0
240− _mcp_initialize() {
241− ready_attempts=$((ready_attempts + 1))
242− [[ "$ready_attempts" -ge 3 ]]
243− }
244− _open_url() { opener_attempt="$ready_attempts"; }
245− _wait_mcp_ready ignored ignored
246− _open_url 'https://fixture.invalid/secret'
247− [[ "$ready_attempts" == 3 && "$opener_attempt" == 3 ]]
248−)
249−
250−# Open refuses a quiesced box before hb up can clear its marker.
251−(
252− # shellcheck disable=SC1090
253− source "$PROJECT_ROOT/box"
254− calls="$tmp/open-quiesced.calls"
255− _host_preflight() { :; }
256− _acquire_lock() { :; }
257− _reg_port() { printf '4899\n'; }
258− _machine_running() { :; }
259− smolvm() { printf 'smolvm %s\n' "$*" >>"$calls"; }
260− _guest_hb() {
261− printf 'hb %s\n' "$2" >>"$calls"
262− [[ "$2" == is-paused ]]
263− }
264− if (cmd_open quiesced >/dev/null 2>&1); then
265− echo "box open unexpectedly succeeded while quiesced" >&2
266− exit 1
267− fi
268− grep -q 'hb is-paused' "$calls"
269− if grep -q 'hb up' "$calls"; then
270− echo "box open cleared quiesce through hb up" >&2
271− exit 1
272− fi
273− if grep -q 'machine start' "$calls"; then
274− echo "box open started the VM" >&2
275− exit 1
276− fi
277−)
278−
279−# Open refuses a stopped box and never changes its lifecycle state.
280−(
281− # shellcheck disable=SC1090
282− source "$PROJECT_ROOT/box"
283− calls="$tmp/open-stopped.calls"
284− _host_preflight() { :; }
285− _acquire_lock() { :; }
286− _reg_port() { printf '4899\n'; }
287− _machine_running() { return 1; }
288− smolvm() { printf 'smolvm %s\n' "$*" >>"$calls"; }
289− _guest_hb() { printf 'hb %s\n' "$2" >>"$calls"; }
290− if (cmd_open stopped >/dev/null 2>&1); then
291− echo "box open accepted a stopped box" >&2
292− exit 1
293− fi
294− [[ ! -e "$calls" ]] || ! grep -q 'machine start' "$calls"
295−)
296−
297−# Repair refreshes assets but preserves a pre-existing durable quiesce policy.
298−(
299− # shellcheck disable=SC1090
300− source "$PROJECT_ROOT/box"
301− calls="$tmp/repair-quiesced.calls"
302− _host_preflight() { :; }
303− _acquire_lock() { :; }
304− _machine_exists() { :; }
305− smolvm() { printf 'smolvm %s\n' "$*" >>"$calls"; }
306− _provision_into() { printf 'provision %s %s\n' "$1" "$2" >>"$calls"; }
307− _guest_hb() {
308− printf 'hb %s\n' "$2" >>"$calls"
309− [[ "$2" == is-paused || "$2" == doctor ]]
310− }
311− cmd_repair quiesced >/dev/null
312− grep -q 'provision quiesced full' "$calls"
313− grep -q 'hb doctor' "$calls"
314− if grep -Eq 'hb (up|resume)' "$calls"; then
315− echo "repair resumed a previously quiesced box" >&2
316− exit 1
317− fi
318−)
319−
320−# Repair validates a configured bundle before constructing the provision tar.
321−(
322− # shellcheck disable=SC1090
323− source "$PROJECT_ROOT/box"
324− calls="$tmp/repair-invalid-bundle.calls"
325− HERMES_GIT_BUNDLE='../invalid.bundle'
326− _host_preflight() { :; }
327− _acquire_lock() { :; }
328− _machine_exists() { :; }
329− _guest_hb() { [[ "$2" == is-paused ]]; }
330− smolvm() { printf '%s\n' "$*" >>"$calls"; }
331− if (cmd_repair invalid-bundle >/dev/null 2>&1); then
332− echo "repair accepted an invalid Hermes bundle path" >&2
333− exit 1
334− fi
335− if grep -q 'machine exec -i' "$calls"; then
336− echo "repair streamed provision context before bundle validation" >&2
337− exit 1
338− fi
339−)
340−
341−# Asset-only refreshes neither validate nor transport the optional source bundle.
342−(
343− # shellcheck disable=SC1090
344− source "$PROJECT_ROOT/box"
345− calls="$tmp/provision-mode.calls"
346− HERMES_GIT_BUNDLE='artifacts/hermes.bundle'
347− _validate_bundle() { printf 'validate\n' >>"$calls"; }
348− tar() { printf 'tar %s\n' "$*" >>"$calls"; printf 'fixture'; }
349− smolvm() { if [[ "$*" == *'machine exec -i'* ]]; then cat >/dev/null; fi; }
350− _provision_into fixture assets >/dev/null
351− [[ ! -e "$calls" ]] || ! grep -q '^validate$\|artifacts/hermes.bundle' "$calls"
352− _provision_into fixture full >/dev/null
353− grep -q '^validate$' "$calls"
354− grep -q 'artifacts/hermes.bundle' "$calls"
355−)
356−
357−echo "doctor-mcp regression checks passed"
tests/regressions-lock-rollback.sh+0−326
@@ -1,326 +0,0 @@
1−#!/usr/bin/env bash
2−# shellcheck disable=SC2016,SC2030,SC2031,SC2329
3−set -euo pipefail
4−
5−# shellcheck source=tests/lib.sh
6−source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
7−
8−mkdir -p "$tmp/valid/home/agent/workspace" "$tmp/gnupg" "$tmp/host-tmp"
9−chmod 0700 "$tmp/gnupg"
10−printf 'portable state\n' >"$tmp/valid/home/agent/workspace/probe.txt"
11−tar czf "$tmp/valid.tgz" -C "$tmp/valid" .
12−export GNUPGHOME="$tmp/gnupg"
13−export BOX_PASSPHRASE='regression-passphrase'
14−gpg --batch --yes --pinentry-mode loopback --passphrase "$BOX_PASSPHRASE" \
15− --symmetric --cipher-algo AES256 -o "$tmp/valid.tar.gz.gpg" "$tmp/valid.tgz"
16−
17−# Legacy registry rows remain readable and reserve their Executor port.
18−(
19− # shellcheck disable=SC1090
20− source "$PROJECT_ROOT/box"
21− REG="$tmp/legacy-registry"
22− printf 'legacy\t4900\n' >"$REG"
23− _reg_exists legacy
24− [[ "$(_reg_port legacy)" == 4900 ]]
25− [[ -z "$(_reg_api_port legacy)" ]]
26− if _port_free 4900; then
27− echo "legacy registry port was reported free" >&2
28− exit 1
29− fi
30−)
31−
32−# A failed precheck must preserve a same-named unmanaged smolvm machine.
33−preexisting_repo="$tmp/preexisting-repo"
34−make_repo "$preexisting_repo"
35−mkdir -p "$tmp/preexisting-state/owned-elsewhere"
36−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/preexisting.calls" \
37− SMOLVM_STATE_DIR="$tmp/preexisting-state" SMOLVM_BEHAVIOR=success \
38− "$preexisting_repo/box" new owned-elsewhere >/dev/null 2>&1; then
39− echo "creation over a preexisting machine unexpectedly succeeded" >&2
40− exit 1
41−fi
42−[[ -d "$tmp/preexisting-state/owned-elsewhere" ]]
43−
44−# If create reports failure after another machine appears, ownership is unknown
45−# and cleanup must preserve that race winner.
46−race_repo="$tmp/race-repo"
47−make_repo "$race_repo"
48−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/race.calls" \
49− SMOLVM_STATE_DIR="$tmp/race-state" SMOLVM_BEHAVIOR=create-race \
50− "$race_repo/box" new race-winner >/dev/null 2>&1; then
51− echo "ambiguous create race unexpectedly succeeded" >&2
52− exit 1
53−fi
54−[[ -d "$tmp/race-state/race-winner" ]]
55−if grep -q 'machine delete' "$tmp/race.calls"; then
56− echo "ambiguous create race winner was deleted" >&2
57− exit 1
58−fi
59−
60−# TERM during provisioning rolls back the invocation-owned VM and registry.
61−signal_repo="$tmp/signal-repo"
62−make_repo "$signal_repo"
63−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/signal.calls" \
64− SMOLVM_STATE_DIR="$tmp/signal-state" SMOLVM_BEHAVIOR=block-provision \
65− "$signal_repo/box" new signal-probe >/dev/null 2>&1; then
66− echo "signal-interrupted creation unexpectedly succeeded" >&2
67− exit 1
68−fi
69−[[ ! -d "$tmp/signal-state/signal-probe" ]]
70−[[ ! -s "$signal_repo/.boxes" ]]
71−
72−# A signal delivered while create itself succeeds is deferred until ownership
73−# and registry metadata are recorded, then cleanup safely removes the owned VM.
74−create_signal_repo="$tmp/create-signal-repo"
75−make_repo "$create_signal_repo"
76−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/create-signal.calls" \
77− SMOLVM_STATE_DIR="$tmp/create-signal-state" SMOLVM_BEHAVIOR=signal-create-success \
78− "$create_signal_repo/box" new create-signal-probe >/dev/null 2>&1; then
79− echo "create-time signal unexpectedly succeeded" >&2
80− exit 1
81−fi
82−[[ ! -d "$tmp/create-signal-state/create-signal-probe" ]]
83−[[ ! -s "$create_signal_repo/.boxes" ]]
84−grep -q 'machine create' "$tmp/create-signal.calls"
85−grep -q -- '--cpus 4 --mem 8192 --overlay 64' "$tmp/create-signal.calls"
86−grep -q 'machine delete' "$tmp/create-signal.calls"
87−if grep -q 'machine start' "$tmp/create-signal.calls"; then
88− echo "create-time signal was not honored before start" >&2
89− exit 1
90−fi
91−
92−# Optional Hermes API exposure constructs a distinct loopback forward and
93−# passes its guest bridge port to the persistent workload.
94−api_repo="$tmp/api-repo"
95−make_repo "$api_repo"
96−printf '\nEXPOSE_HERMES_API=1\nHERMES_API_PORT_LO=8650\nHERMES_API_PORT_HI=8650\n' >>"$api_repo/box.env"
97−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/api.calls" \
98− SMOLVM_STATE_DIR="$tmp/api-state" SMOLVM_BEHAVIOR=fail-exec \
99− "$api_repo/box" new api-probe >/dev/null 2>&1; then
100− echo "mock API provisioning failure unexpectedly succeeded" >&2
101− exit 1
102−fi
103−grep -q -- '-p 8650:8650' "$tmp/api.calls"
104−grep -q 'hb-workload.*8650' "$tmp/api.calls"
105−
106−# Native import fails closed. External provider state requires explicit consent
107−# before any VM command, and failures/signals after disable never restart it.
108−python3 - "$tmp/hermes-import.zip" <<'PY'
109−import sys, zipfile
110−with zipfile.ZipFile(sys.argv[1], "w") as archive:
111− archive.writestr("config.yaml", "model: fixture\n")
112− archive.writestr(".env", "DISCORD_BOT_TOKEN=fixture\n")
113− archive.writestr("tmp/transient.log", "do not migrate\n")
114− archive.writestr("_external/.honcho/config.json", "{}\n")
115−PY
116−import_repo="$tmp/import-repo"
117−make_repo "$import_repo"
118−mkdir -p "$tmp/import-state/import-probe"
119−printf 'import-probe\t\n' >"$import_repo/.boxes"
120−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/import-no-consent.calls" \
121− SMOLVM_STATE_DIR="$tmp/import-state" SMOLVM_BEHAVIOR=success \
122− "$import_repo/box" import-hermes import-probe "$tmp/hermes-import.zip" >/dev/null 2>&1; then
123− echo "external import without consent unexpectedly succeeded" >&2
124− exit 1
125−fi
126−[[ ! -e "$tmp/import-no-consent.calls" ]]
127−
128−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/import-fail.calls" \
129− SMOLVM_STATE_DIR="$tmp/import-state" SMOLVM_BEHAVIOR=import-post-fail \
130− "$import_repo/box" import-hermes import-probe "$tmp/hermes-import.zip" \
131− --external .honcho >/dev/null 2>&1; then
132− echo "post-import verification failure unexpectedly succeeded" >&2
133− exit 1
134−fi
135−[[ -e "$tmp/import-state/import-probe/gateway-disabled" ]]
136−[[ ! -e "$tmp/import-state/import-probe/gateway-restarted" ]]
137−grep -q 'hb gateway-disable' "$tmp/import-fail.calls"
138−grep -q 'hb resume' "$tmp/import-fail.calls"
139−grep -q '/data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls"
140−if grep -q '/root/hermes-import-' "$tmp/import-fail.calls"; then
141− echo "Hermes import still stages beneath root-only storage" >&2
142− exit 1
143−fi
144−import_dir_line="$(grep -n 'install -d -o agent -g agent -m 0700 /data/home/agent/.config/hermes-box' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)"
145−import_cp_line="$(grep -n 'machine cp.*\.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)"
146−import_chown_line="$(grep -n 'chown agent:agent /data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)"
147−import_chmod_line="$(grep -n 'chmod 0600 /data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)"
148−import_setpriv_line="$(grep -n 'setpriv.*hermes-state.*import-zip.*/data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)"
149−[[ -n "$import_dir_line" && -n "$import_cp_line" && -n "$import_chown_line" && -n "$import_chmod_line" && -n "$import_setpriv_line" ]]
150−[[ "$import_dir_line" -lt "$import_cp_line" && "$import_cp_line" -lt "$import_chown_line" && "$import_chown_line" -lt "$import_chmod_line" && "$import_chmod_line" -lt "$import_setpriv_line" ]]
151−import_remove_line="$(grep -n 'rm -f -- /data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)"
152−import_resume_line="$(grep -n 'hb resume' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)"
153−[[ -n "$import_remove_line" && -n "$import_resume_line" && "$import_remove_line" -lt "$import_resume_line" ]]
154−
155−rm -f "$tmp/import-state/import-probe/paused"
156−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/import-signal.calls" \
157− SMOLVM_STATE_DIR="$tmp/import-state" SMOLVM_BEHAVIOR=signal-import \
158− "$import_repo/box" import-hermes import-probe "$tmp/hermes-import.zip" \
159− --external .honcho >/dev/null 2>&1; then
160− echo "signal-interrupted import unexpectedly succeeded" >&2
161− exit 1
162−fi
163−[[ -e "$tmp/import-state/import-probe/gateway-disabled" ]]
164−[[ ! -e "$tmp/import-state/import-probe/gateway-restarted" ]]
165−grep -q 'hb resume' "$tmp/import-signal.calls"
166−signal_remove_line="$(grep -n 'rm -f -- /data/home/agent/.config/hermes-box/import-' "$tmp/import-signal.calls" | head -1 | cut -d: -f1)"
167−signal_resume_line="$(grep -n 'hb resume' "$tmp/import-signal.calls" | head -1 | cut -d: -f1)"
168−[[ -n "$signal_remove_line" && -n "$signal_resume_line" && "$signal_remove_line" -lt "$signal_resume_line" ]]
169−
170−# Import temporarily starts a stopped target, preserves its unpaused policy,
171−# keeps the gateway disabled, and returns it to stopped on success or failure.
172−for stopped_case in success failure; do
173− stopped_name="stopped-import-$stopped_case"
174− mkdir -p "$tmp/import-state/$stopped_name"
175− printf '%s\t\n' "$stopped_name" >>"$import_repo/.boxes"
176− behavior=inactive
177− [[ "$stopped_case" == success ]] || behavior=inactive-import-post-fail
178− if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/${stopped_name}.calls" \
179− SMOLVM_STATE_DIR="$tmp/import-state" SMOLVM_BEHAVIOR="$behavior" \
180− "$import_repo/box" import-hermes "$stopped_name" "$tmp/hermes-import.zip" \
181− --external .honcho >"$tmp/${stopped_name}.output" 2>&1; then
182− [[ "$stopped_case" == success ]] || {
183− echo "stopped import failure case unexpectedly succeeded" >&2; exit 1;
184− }
185− else
186− [[ "$stopped_case" == failure ]] || {
187− echo "stopped import success case failed" >&2; exit 1;
188− }
189− fi
190− grep -q 'machine start' "$tmp/${stopped_name}.calls"
191− grep -q 'machine stop' "$tmp/${stopped_name}.calls"
192− grep -q 'hb gateway-disable' "$tmp/${stopped_name}.calls"
193− [[ -e "$tmp/import-state/$stopped_name/gateway-disabled" ]]
194− [[ ! -e "$tmp/import-state/$stopped_name/paused" ]]
195− [[ ! -e "$tmp/import-state/$stopped_name/gateway-restarted" ]]
196−done
197−grep -q 'Native import will intentionally skip 1 non-portable or transient file(s)' \
198− "$tmp/stopped-import-success.output"
199−grep -q 'Hermes import verified. Gateway remains disabled' "$tmp/stopped-import-success.output"
200−
201−# Pause may have taken effect even when the pause command reports failure. The
202−# existing box is tracked early and resumed during cleanup.
203−pause_repo="$tmp/pause-repo"
204−make_repo "$pause_repo"
205−mkdir -p "$tmp/pause-state/pause-probe"
206−printf 'pause-probe\t\n' >"$pause_repo/.boxes"
207−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/pause.calls" \
208− SMOLVM_STATE_DIR="$tmp/pause-state" SMOLVM_BEHAVIOR=pause-fail \
209− "$pause_repo/box" save pause-probe "$tmp/pause.gpg" >/dev/null 2>&1; then
210− echo "failed pause unexpectedly allowed save" >&2
211− exit 1
212−fi
213−grep -q 'hb resume' "$tmp/pause.calls"
214−[[ ! -e "$tmp/pause-state/pause-probe/paused" ]]
215−
216−# Rollback remains best-effort when another process owns the registry lock: VM
217−# deletion and operation-lock cleanup still happen, while metadata is preserved.
218−contention_repo="$tmp/contention-repo"
219−make_repo "$contention_repo"
220−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/contention.calls" \
221− SMOLVM_STATE_DIR="$tmp/contention-state" SMOLVM_BEHAVIOR=registry-contention \
222− SMOLVM_REGISTRY_LOCK_DIR="$contention_repo/.box-locks/registry.lock" \
223− SMOLVM_REGISTRY_LOCK_OWNER_PID="$$" \
224− "$contention_repo/box" new contention-probe >/dev/null 2>&1; then
225− echo "registry-contention provisioning unexpectedly succeeded" >&2
226− exit 1
227−fi
228−[[ ! -d "$tmp/contention-state/contention-probe" ]]
229−grep -q '^contention-probe' "$contention_repo/.boxes"
230−[[ ! -e "$contention_repo/.box-locks/contention-probe.lock" ]]
231−
232−# Rollback recovers a stale registry lock without aborting best-effort cleanup.
233−stale_lock_repo="$tmp/stale-lock-repo"
234−make_repo "$stale_lock_repo"
235−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/stale-lock.calls" \
236− SMOLVM_STATE_DIR="$tmp/stale-lock-state" SMOLVM_BEHAVIOR=stale-registry-lock \
237− SMOLVM_REGISTRY_LOCK_DIR="$stale_lock_repo/.box-locks/registry.lock" \
238− "$stale_lock_repo/box" new stale-lock-probe >/dev/null 2>&1; then
239− echo "stale-lock provisioning unexpectedly succeeded" >&2
240− exit 1
241−fi
242−[[ ! -d "$tmp/stale-lock-state/stale-lock-probe" ]]
243−[[ ! -s "$stale_lock_repo/.boxes" ]]
244−[[ ! -e "$stale_lock_repo/.box-locks/registry.lock" ]]
245−[[ ! -e "$stale_lock_repo/.box-locks/stale-lock-probe.lock" ]]
246−
247−# A failed delete preserves both the incomplete machine and its registry record.
248−delete_repo="$tmp/delete-repo"
249−make_repo "$delete_repo"
250−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/delete.calls" \
251− SMOLVM_STATE_DIR="$tmp/delete-state" SMOLVM_BEHAVIOR=delete-fail \
252− "$delete_repo/box" new delete-probe >/dev/null 2>&1; then
253− echo "delete-failure provisioning unexpectedly succeeded" >&2
254− exit 1
255−fi
256−[[ -d "$tmp/delete-state/delete-probe" ]]
257−grep -q '^delete-probe' "$delete_repo/.boxes"
258−
259−# A delete error plus a generic status error is never interpreted as absence.
260−delete_status_repo="$tmp/delete-status-repo"
261−make_repo "$delete_status_repo"
262−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/delete-status.calls" \
263− SMOLVM_STATE_DIR="$tmp/delete-status-state" SMOLVM_BEHAVIOR=delete-status-fail \
264− "$delete_status_repo/box" new delete-status-probe >/dev/null 2>&1; then
265− echo "delete/status-failure provisioning unexpectedly succeeded" >&2
266− exit 1
267−fi
268−[[ -d "$tmp/delete-status-state/delete-status-probe" ]]
269−grep -q '^delete-status-probe' "$delete_status_repo/.boxes"
270−
271−# Registration happens before start, so a start failure plus delete failure
272−# leaves enough host metadata for explicit recovery.
273−start_delete_repo="$tmp/start-delete-repo"
274−make_repo "$start_delete_repo"
275−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/start-delete.calls" \
276− SMOLVM_STATE_DIR="$tmp/start-delete-state" SMOLVM_BEHAVIOR=start-delete-fail \
277− "$start_delete_repo/box" new start-delete-probe >/dev/null 2>&1; then
278− echo "start/delete failure unexpectedly succeeded" >&2
279− exit 1
280−fi
281−[[ -d "$tmp/start-delete-state/start-delete-probe" ]]
282−grep -q '^start-delete-probe' "$start_delete_repo/.boxes"
283−
284−# Save failures remove the guest plaintext before resuming Executor.
285−save_repo="$tmp/save-repo"
286−make_repo "$save_repo"
287−mkdir -p "$tmp/save-state/save-probe"
288−printf 'save-probe\t\n' >"$save_repo/.boxes"
289−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/save.calls" \
290− SMOLVM_STATE_DIR="$tmp/save-state" SMOLVM_BEHAVIOR=save-cp-fail \
291− SMOLVM_ARCHIVE_SOURCE="$tmp/valid.tgz" TMPDIR="$tmp/host-tmp" \
292− "$save_repo/box" save save-probe "$tmp/save.gpg" >/dev/null 2>&1; then
293− echo "save copy failure unexpectedly succeeded" >&2
294− exit 1
295−fi
296−grep -q '/root/hb-data-' "$tmp/save.calls"
297−remove_line="$(grep -n 'rm -f -- /root/hb-data-' "$tmp/save.calls" | head -1 | cut -d: -f1)"
298−resume_line="$(grep -n 'hb resume' "$tmp/save.calls" | head -1 | cut -d: -f1)"
299−[[ -n "$remove_line" && -n "$resume_line" && "$remove_line" -lt "$resume_line" ]]
300−[[ -z "$(find "$tmp/host-tmp" -mindepth 1 -maxdepth 1 -print -quit)" ]]
301−
302−# Restore extraction failures propagate, delete the incomplete VM, and contain
303−# the fail-fast extraction and guest-temp trap in the invoked command.
304−restore_repo="$tmp/restore-repo"
305−make_repo "$restore_repo"
306−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/restore.calls" \
307− SMOLVM_STATE_DIR="$tmp/restore-state" SMOLVM_BEHAVIOR=restore-tar-fail \
308− "$restore_repo/box" load "$tmp/valid.tar.gz.gpg" restore-probe >/dev/null 2>&1; then
309− echo "restore tar failure unexpectedly succeeded" >&2
310− exit 1
311−fi
312−[[ ! -d "$tmp/restore-state/restore-probe" ]]
313−if grep -q 'hb resume' "$tmp/restore.calls"; then
314− echo "failed restore resumed Executor before deleting its new VM" >&2
315− exit 1
316−fi
317−grep -q 'machine delete' "$tmp/restore.calls"
318−grep -q 'chmod 0600 /root/hb-restore-' "$tmp/restore.calls"
319−restore_remove_line="$(grep -n 'rm -f -- /root/hb-restore-' "$tmp/restore.calls" | head -1 | cut -d: -f1)"
320−restore_delete_line="$(grep -n 'machine delete' "$tmp/restore.calls" | head -1 | cut -d: -f1)"
321−[[ -n "$restore_remove_line" && -n "$restore_delete_line" && "$restore_remove_line" -lt "$restore_delete_line" ]]
322−grep -q 'set -euo pipefail' "$tmp/restore.calls"
323−grep -q 'trap.*rm -f --.*archive' "$tmp/restore.calls"
324−
325−
326−echo "lock-rollback regression checks passed"
tests/regressions-tx9-host.sh+0−157
@@ -1,157 +0,0 @@
1−#!/usr/bin/env bash
2−# shellcheck disable=SC2016,SC2030,SC2031,SC2329
3−set -euo pipefail
4−
5−# shellcheck source=tests/lib.sh
6−source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
7−
8−# Basic VM operations do not require HTTP health tools; health operations do.
9−(
10− # shellcheck disable=SC1090
11− source "$PROJECT_ROOT/box"
12− have() { [[ "$1" == awk || "$1" == smolvm ]]; }
13− _host_preflight basic
14−)
15−for missing_health_tool in curl jq mktemp; do
16− if (
17− # shellcheck disable=SC1090
18− source "$PROJECT_ROOT/box"
19− have() { [[ "$1" != "$missing_health_tool" ]]; }
20− _host_preflight health
21− ) 2>/dev/null; then
22− echo "health preflight passed without $missing_health_tool" >&2
23− exit 1
24− fi
25−done
26−
27−# Scheduled host health gates on an already-running box and never starts one.
28−host_root="$tmp/tx9-host-root"
29−mkdir -p "$host_root" "$tmp/tx9-host-state/scheduled"
30−printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >>"${TX9_BOX_CALLS:?}"\n' >"$host_root/box"
31−chmod +x "$host_root/box"
32−if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/tx9-inactive.smolvm" \
33− SMOLVM_STATE_DIR="$tmp/tx9-host-state" SMOLVM_BEHAVIOR=inactive \
34− SMOLVM_CALLED_UNUSED=1 TX9_ROOT="$host_root" TX9_BOX_CALLS="$tmp/tx9-inactive.box" \
35− "$PROJECT_ROOT/ops/tx9-host" health scheduled >/dev/null 2>&1; then
36− echo "scheduled health accepted an inactive box" >&2
37− exit 1
38−fi
39−[[ ! -e "$tmp/tx9-inactive.box" ]]
40−if grep -q 'machine start' "$tmp/tx9-inactive.smolvm"; then
41− echo "scheduled health started an inactive box" >&2
42− exit 1
43−fi
44−PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/tx9-active.smolvm" \
45− SMOLVM_STATE_DIR="$tmp/tx9-host-state" SMOLVM_BEHAVIOR=success \
46− TX9_ROOT="$host_root" TX9_BOX_CALLS="$tmp/tx9-active.box" \
47− "$PROJECT_ROOT/ops/tx9-host" health scheduled >/dev/null
48−grep -q '^doctor scheduled$' "$tmp/tx9-active.box"
49−
50−# Sourced configuration cannot redirect an operation to another box name.
51−mkdir -p "$tmp/tx9-config/boxes"
52−printf 'name=global-override\nTX9_BOX_CONFIG_DIR=%s\n' "$tmp/tx9-config/boxes" >"$tmp/tx9-config/global.conf"
53−printf 'name=box-override\n' >"$tmp/tx9-config/boxes/scheduled.conf"
54−PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/tx9-config-name.smolvm" \
55− SMOLVM_STATE_DIR="$tmp/tx9-host-state" SMOLVM_BEHAVIOR=success \
56− TX9_CONFIG="$tmp/tx9-config/global.conf" TX9_ROOT="$host_root" \
57− TX9_BOX_CALLS="$tmp/tx9-config-name.box" \
58− "$PROJECT_ROOT/ops/tx9-host" health scheduled >/dev/null
59−[[ "$(cat "$tmp/tx9-config-name.box")" == 'doctor scheduled' ]]
60−
61−# The foreground supervisor starts once and restarts an unexpectedly stopped VM.
62−PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/tx9-supervise.smolvm" \
63− SMOLVM_STATE_DIR="$tmp/tx9-host-state" SMOLVM_BEHAVIOR=inactive \
64− TX9_ROOT="$host_root" TX9_BOX_CALLS="$tmp/tx9-supervise.box" TX9_SUPERVISE_ONCE=1 \
65− "$PROJECT_ROOT/ops/tx9-host" supervise scheduled >/dev/null 2>&1
66−[[ "$(grep -c '^start scheduled$' "$tmp/tx9-supervise.box")" == 2 ]]
67−
68−# A supervisor that receives stop intent exits without probing or restarting.
69−PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/tx9-supervise-stop.smolvm" \
70− SMOLVM_STATE_DIR="$tmp/tx9-host-state" SMOLVM_BEHAVIOR=inactive \
71− TX9_ROOT="$host_root" TX9_BOX_CALLS="$tmp/tx9-supervise-stop.box" \
72− TX9_SUPERVISE_INTERVAL_SECONDS=300 \
73− "$PROJECT_ROOT/ops/tx9-host" supervise scheduled >/dev/null 2>&1 &
74−supervisor_pid=$!
75−pids+=("$supervisor_pid")
76−wait_for_pattern '^start scheduled$' "$tmp/tx9-supervise-stop.box"
77−kill -TERM "$supervisor_pid"
78−wait "$supervisor_pid"
79−[[ "$(grep -c '^start scheduled$' "$tmp/tx9-supervise-stop.box")" == 1 ]]
80−[[ ! -e "$tmp/tx9-supervise-stop.smolvm" ]]
81−
82−# An unset systemd credential directory does not resolve to /backup-passphrase.
83−if env -u BOX_PASSPHRASE_FILE -u CREDENTIALS_DIRECTORY \
84− PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/tx9-no-credential.smolvm" \
85− SMOLVM_STATE_DIR="$tmp/tx9-host-state" SMOLVM_BEHAVIOR=success \
86− TX9_ROOT="$host_root" TX9_BOX_CALLS="$tmp/tx9-no-credential.box" \
87− "$PROJECT_ROOT/ops/tx9-host" backup scheduled >/dev/null 2>&1; then
88− echo "scheduled backup accepted a missing credential" >&2
89− exit 1
90−fi
91−[[ ! -e "$tmp/tx9-no-credential.box" ]] || ! grep -q '^save ' "$tmp/tx9-no-credential.box"
92−
93−# Credential files without a trailing newline are accepted intact.
94−printf 'no-newline-passphrase' >"$tmp/passphrase-no-newline"
95−(
96− # shellcheck disable=SC1090
97− source "$PROJECT_ROOT/box"
98− _BOX_PASS=""
99− BOX_PASSPHRASE_FILE="$tmp/passphrase-no-newline"
100− _resolve_passphrase
101− [[ "$_BOX_PASS" == no-newline-passphrase ]]
102−)
103−
104−# tx9-backup-prune keeps the newest TX9_BACKUP_RETAIN_COUNT backups, applies
105−# an optional TX9_BACKUP_RETAIN_DAYS cap on top, leaves a missing NAS
106−# directory alone, never touches another box's backups, and validates input.
107−#
108−# CRITICAL ISOLATION: the script sources /etc/tx9/tx9.conf and the per-box
109−# conf AFTER the environment, so a real host config overrides the test's
110−# TX9_BACKUP_DIR — on a production host, an unisolated run points the prune
111−# at the REAL backup directory and (given permissions) deletes real backups.
112−# Observed live on nexus: /etc/tx9/boxes/hermes.conf redirected this test to
113−# /var/backups/tx9; only a permission error stopped it. Point both config
114−# knobs at test-owned paths for every invocation below.
115−export TX9_CONFIG="/dev/null"
116−export TX9_BOX_CONFIG_DIR="$tmp/prune-no-box-config"
117−prune_dir="$tmp/prune-backups"
118−mkdir -p "$prune_dir"
119−for day in 10 9 8 7 6 5 4 3 2 1; do
120− stamp="$(date -u -d "-${day} days" +%Y%m%d-%H%M%SZ)"
121− : >"$prune_dir/hermes-${stamp}.tar.gz.gpg"
122− : >"$prune_dir/hermes-${stamp}.tar.gz.gpg.sha256"
123−done
124−: >"$prune_dir/other-$(date -u -d '-1 days' +%Y%m%d-%H%M%SZ).tar.gz.gpg"
125−: >"$prune_dir/other-$(date -u -d '-1 days' +%Y%m%d-%H%M%SZ).tar.gz.gpg.sha256"
126−
127−TX9_BACKUP_DIR="$prune_dir" TX9_BACKUP_RETAIN_COUNT=3 \
128− "$PROJECT_ROOT/ops/tx9-backup-prune" hermes >/dev/null
129−[[ "$(find "$prune_dir" -maxdepth 1 -name 'hermes-*.tar.gz.gpg' | wc -l)" == 3 ]]
130−[[ "$(find "$prune_dir" -maxdepth 1 -name 'other-*.tar.gz.gpg' | wc -l)" == 1 ]]
131−# The 3 survivors are the 3 newest (days 1-3 back); each kept .tar.gz.gpg
132−# still has its matching .sha256 companion.
133−for day in 1 2 3; do
134− stamp="$(date -u -d "-${day} days" +%Y%m%d-%H%M%SZ)"
135− [[ -e "$prune_dir/hermes-${stamp}.tar.gz.gpg" ]]
136− [[ -e "$prune_dir/hermes-${stamp}.tar.gz.gpg.sha256" ]]
137−done
138−
139−TX9_BACKUP_DIR="$prune_dir" TX9_BACKUP_RETAIN_COUNT=3 TX9_BACKUP_RETAIN_DAYS=2 \
140− "$PROJECT_ROOT/ops/tx9-backup-prune" hermes >/dev/null
141−[[ "$(find "$prune_dir" -maxdepth 1 -name 'hermes-*.tar.gz.gpg' | wc -l)" == 2 ]]
142−
143−missing_nas="$tmp/missing-nas"
144−TX9_BACKUP_DIR="$prune_dir" TX9_NAS_DIR="$missing_nas" TX9_BACKUP_RETAIN_COUNT=2 \
145− "$PROJECT_ROOT/ops/tx9-backup-prune" hermes >/dev/null
146−[[ ! -e "$missing_nas" ]]
147−
148−if "$PROJECT_ROOT/ops/tx9-backup-prune" '../escape' >/dev/null 2>&1; then
149− echo "tx9-backup-prune accepted an unsafe box name" >&2
150− exit 1
151−fi
152−if TX9_BACKUP_RETAIN_COUNT=not-a-number "$PROJECT_ROOT/ops/tx9-backup-prune" hermes >/dev/null 2>&1; then
153− echo "tx9-backup-prune accepted a non-numeric retain count" >&2
154− exit 1
155−fi
156−
157−echo "tx9-host regression checks passed"
tests/static.sh+29−95
@@ -10,113 +10,47 @@ cd "$ROOT" · 9 unmodified lines
1010 regression_files=(tests/regressions-*.sh)
1111
1212 files=(
13− box
1413 guest/hb
1514 guest/hb-workload
1615 guest/lib-mcp.sh
1716 guest/profile.sh
1817 guest/agent-bash-profile.sh
1918 provision/provision.sh
20− ops/tx9-host
21− ops/tx9-backup-prune
19+ docker/entrypoint.sh
20+ docker/executor-entrypoint.sh
2221 tests/lib.sh
23− tests/lifecycle-smoke.sh
2422 tests/hermes-state.sh
25− tests/cli-surface.sh
2623 "${regression_files[@]}"
27− tests/fixtures/smolvm
2824 )
2925
3026 bash -n "${files[@]}"
31−for file in box guest/hb guest/hb-workload guest/hermes-state provision/provision.sh ops/tx9-host ops/tx9-backup-prune tests/lifecycle-smoke.sh tests/hermes-state.sh tests/cli-surface.sh "${regression_files[@]}" tests/fixtures/smolvm; do
27+python3 -c 'compile(open("guest/hermes-state", encoding="utf-8").read(), "guest/hermes-state", "exec")'
28+for file in guest/hb guest/hb-workload guest/hermes-state provision/provision.sh tests/hermes-state.sh "${regression_files[@]}"; do
3229 [[ -x "$file" ]] || { echo "not executable: $file" >&2; exit 1; }
3330 done
3431
32+# --- guest/hb invariants ------------------------------------------------
3533 grep -q 'WIRE_EXECUTOR_MCP' guest/hb
36−grep -q 'http://127.0.0.1:.*}/mcp' guest/hb
34+# HTTP MCP wiring goes through _executor_url (http://$EXECUTOR_HOST:$PORT/mcp,
35+# loopback by default, container-DNS name in the compose split).
36+grep -q "printf 'http://%s:%s/mcp' \"\$EXECUTOR_HOST\"" guest/hb
37+grep -q "printf 'http://%s:%s/mcp'.*EXECUTOR_PORT:-4788" guest/hb
3738 if grep -q 'args = \["mcp"\]' guest/hb; then
3839 echo "legacy stdio Executor MCP wiring remains" >&2
3940 exit 1
4041 fi
41−grep -q 'trap _cleanup EXIT' box
42−grep -q 'Decrypting and validating archive before creating a VM' box
43−grep -q 'refusing to overwrite existing backup' box
44−grep -q 'guest_tmp="/root/hb-data-' box
45−grep -q 'guest_tmp="/root/hb-restore-' box
46−if grep -Eq 'guest_tmp="/tmp/hb-(data|restore)-' box; then
47− echo "smolvm-incompatible /tmp archive staging remains" >&2
48− exit 1
49−fi
50−if grep -q '\blsof\b' box; then
51− echo "lsof dependency remains" >&2
52− exit 1
53−fi
54−grep -q 'protocolVersion.*2025-03-26' guest/lib-mcp.sh
55−grep -q 'source .*guest/lib-mcp\.sh' box
56−grep -q 'lib-mcp\.sh' guest/hb
57−grep -q 'ca-certificates curl git jq tmux' provision/provision.sh
58−grep -q 'runuser -u agent.*hb.*write-manifest' provision/provision.sh
59−grep -q -- '--commit.*sha' provision/provision.sh
60−grep -q 'HERMES_INSTALLER_SHA256="[0-9a-f]\{64\}"' box.env
61−grep -q 'sha256sum --check --status' provision/provision.sh
62−python3 - <<'PY'
63−from pathlib import Path
64−
65−source = Path("provision/provision.sh").read_text()
66−checksum = source.index('sha256sum --check --status')
67−destructive_checkout = source.index('rm -rf "$install_dir"')
68−if checksum >= destructive_checkout:
69− raise SystemExit("Hermes checkout is modified before installer verification")
70−installer = source.index('if bash "$installer"')
71−user_args = source.index('"${args[@]}"', installer)
72−pinned_dir = source.index('--dir "$install_dir"', user_args)
73−head_check = source.index('git -C "$install_dir" rev-parse HEAD', installer)
74−origin_reset = source.index('git -C "$install_dir" remote set-url origin', head_check)
75−if not installer < user_args < pinned_dir < head_check < origin_reset:
76− raise SystemExit("bundle origin is reset before installer and exact-HEAD verification")
77−
78−box = Path("box").read_text()
79−stage_root = box.index('chown root:root "$stage" "$stage/home"')
80−stage_traverse = box.index('chmod 0711 "$stage" "$stage/home"', stage_root)
81−nested_owner = box.index('chown -R agent:agent "$stage/home/agent"', stage_traverse)
82−staged_verify = box.index('setpriv --reuid=agent', nested_owner)
83−if not stage_root < stage_traverse < nested_owner < staged_verify:
84− raise SystemExit("restore ownership boundary is not established before agent verification")
85−if 'chown agent:agent "$stage"' in box:
86− raise SystemExit("restore stage root is writable by the agent")
87−PY
88−if grep -q 'curl .*install.sh.*|.*bash' provision/provision.sh; then
89− echo "unverified Hermes installer execution remains" >&2
90− exit 1
91−fi
92−grep -q 'HERMES_INSTALL_ARGS cannot override protected installer option' provision/provision.sh
93−if grep -q 'git -C "$install_dir" fetch origin main' provision/provision.sh; then
94− echo "bundle provisioning still fetches the public origin" >&2
95− exit 1
96−fi
97−grep -q 'BOX_OVERLAY_GIB="64"' box.env
98−grep -q 'gateway-disable' box
99−grep -Fq -- '--exclude="*/.hermes/*.db-wal"' box
100−if grep -Fq -- '--exclude="*.db-wal"' box; then
101− echo "unrelated /data SQLite WAL files are still excluded" >&2
102− exit 1
103−fi
104−grep -q 'LoadCredential=backup-passphrase' ops/systemd/tx9-backup@.service
105−if grep -q '^Requires=tx9-box@' ops/systemd/tx9-*.service; then
106− echo "timer service still pulls the box service active" >&2
107− exit 1
108−fi
109−grep -q '^Type=simple$' ops/systemd/tx9-box@.service
110−grep -q 'tx9-host supervise %i' ops/systemd/tx9-box@.service
111−grep -Fq 'ExecStop=-/bin/kill -TERM $MAINPID' ops/systemd/tx9-box@.service
112−if grep -q '^RemainAfterExit=' ops/systemd/tx9-box@.service; then
113− echo "box supervisor is still modeled as a oneshot service" >&2
114− exit 1
115−fi
11642 if grep -q 'chown -R' guest/hb; then
11743 echo "reconcile still recursively changes durable-tree ownership" >&2
11844 exit 1
11945 fi
46+grep -q 'Executor target port closed while quiesced' guest/hb
47+grep -q 'gateway-is-disabled' guest/hb
48+grep -q 'LEGACY_QUIESCE_FILE' guest/hb guest/hb-workload
49+# The remote-executor override must outrank the on-disk token env, which a
50+# restore can carry stale from the source box.
51+grep -q 'BOXD_EXECUTOR_TOKEN' guest/hb
52+
53+# --- guest/hb-workload invariants ----------------------------------------
12054 grep -q 'EXECUTOR_TARGET_PORT="${EXECUTOR_PORT:-4788}"' guest/hb-workload
12155 grep -q 'TCP:127.0.0.1:$EXECUTOR_TARGET_PORT' guest/hb-workload
12256 grep -q '_wait_api_ready' guest/hb-workload
@@ -125,18 +59,18 @@ grep -q '\[ "$reconciled" = 1 \]' guest/hb-workload · 2 unmodified lines
12559 grep -q '_refresh_targets' guest/hb-workload
12660 grep -q '\. "$BOX_ENV"' guest/hb-workload
12761 grep -q '_stop_executor_bridge' guest/hb-workload
128−grep -q 'LEGACY_QUIESCE_FILE' guest/hb guest/hb-workload
129−grep -q 'Executor target port closed while quiesced' guest/hb
130−grep -q 'gateway-is-disabled' guest/hb box
131−grep -q 'EXECUTOR_PORT:-4788.*mcp' guest/hb
132−grep -q 'guest_tmp="/data/home/agent/.config/hermes-box/import-' box
133−if grep -q 'guest_tmp="/root/hermes-import-' box; then
134− echo "Hermes import remains unreadable to the agent" >&2
135− exit 1
136−fi
62+
63+# --- provisioning / config invariants ------------------------------------
64+grep -q 'HERMES_INSTALLER_SHA256="[0-9a-f]\{64\}"' box.env
65+grep -q 'tools) tools_only ;;' provision/provision.sh
66+grep -q 'assets) assets_only ;;' provision/provision.sh
67+
68+# --- docker build assets --------------------------------------------------
69+grep -q 'provision.sh tools' docker/Dockerfile
70+grep -q 'provision.sh assets' docker/Dockerfile
71+grep -q 'EXECUTOR_MCP_TOKEN' docker/executor-entrypoint.sh
72+grep -q -- '--hostname 0.0.0.0' docker/executor-entrypoint.sh
73+
13774 grep -qi 'networking is always enabled' README.md
138−grep -q 'check.yml. runs it on every push' README.md
139−[[ -f .depot/workflows/check.yml ]]
140−grep -q 'make check' .depot/workflows/check.yml
14175
14276 echo "static checks passed"