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
15 runs-on: depot-ubuntu-latest # was: ubuntu-latest. Mapped standard GitHub runner to Depot equivalent.15 runs-on: depot-ubuntu-latest # was: ubuntu-latest. Mapped standard GitHub runner to Depot equivalent.
16 steps:16 steps:
17 - uses: actions/checkout@v417 - 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"
18 - name: Install jq24 - name: Install jq
19 run: |25 run: |
20 sudo apt-get update26 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 @@
1name: 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
8on:
9 push:
10 tags:
11 - "v*"
12
13permissions:
14 contents: write
15
16jobs:
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
14!/artifacts/.gitkeep14!/artifacts/.gitkeep
15 15
16__pycache__/16__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-pin1.PHONY: check syntax lint test check-hermes-pin tx9 go-check dist
2 2
3# tests/regressions-*.sh is globbed so new split-out regression files don't3# tests/regressions-*.sh is globbed so new split-out regression files don't
4# require touching this Makefile.4# require touching this Makefile.
5SHELL_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/smolvm5SHELL_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)
6 6
7syntax:7syntax:
8 bash -n $(SHELL_FILES)8 bash -n $(SHELL_FILES)
9 sh -n scripts/install.sh
9 python3 -c 'compile(open("guest/hermes-state", encoding="utf-8").read(), "guest/hermes-state", "exec")'10 python3 -c 'compile(open("guest/hermes-state", encoding="utf-8").read(), "guest/hermes-state", "exec")'
10 11
11lint:12lint:
12 command -v shellcheck >/dev/null || { echo "shellcheck is required for make check" >&2; exit 1; }13 command -v shellcheck >/dev/null || { echo "shellcheck is required for make check" >&2; exit 1; }
13 shellcheck -x -s bash $(SHELL_FILES)14 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
14 19
15test:20test:
16 command -v python3 >/dev/null || { echo "python3 is required for local regression tests" >&2; exit 1; }21 command -v python3 >/dev/null || { echo "python3 is required for local regression tests" >&2; exit 1; }
17 command -v jq >/dev/null || { echo "jq is required for local regression tests" >&2; exit 1; }22 command -v jq >/dev/null || { echo "jq is required for local regression tests" >&2; exit 1; }
18 ./tests/static.sh23 ./tests/static.sh
19 ./tests/lifecycle-smoke.sh
20 ./tests/hermes-state.sh24 ./tests/hermes-state.sh
21 ./tests/cli-surface.sh
22 for f in tests/regressions-*.sh; do ./"$$f" || exit 1; done25 for f in tests/regressions-*.sh; do ./"$$f" || exit 1; done
23 26
24check: syntax lint test27check: syntax lint test go-check
25 28
26# Not part of `check` — needs network access, so `make check` stays hermetic.29# Not part of `check` — needs network access, so `make check` stays hermetic.
27# Run this on its own to catch Hermes installer drift before it surfaces as a30# 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
45 echo "review the new installer before re-pinning, then update box.env" >&2; \48 echo "review the new installer before re-pinning, then update box.env" >&2; \
46 exit 1; \49 exit 1; \
47 fi50 fi
51
52VERSION ?= 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.
59tx9:
60 go build -buildvcs=false -ldflags "-X github.com/davis7dotsh/tx9/internal/version.Version=$(VERSION)" -o bin/tx9 .
61
62go-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.
72DIST_PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64
73
74dist:
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-lite1# tx9
2 2
3Small, isolated agent VMs with **Claude Code, Codex, Hermes, and Executor**.3Isolated, portable agent boxes with **Claude Code, Codex, Hermes, and
4Each box runs on [smolvm](https://smolmachines.com), has no host filesystem4Executor**, running on Docker. Each box is a pair of containers on a private
5mounts, and keeps portable user state under `/data`.5network — a free-rein agent container and an isolated Executor container —
6 6with all durable state on a named volume that travels as an encrypted,
7[Usage guide and quick start](docs/usage-guide.html)7validated backup archive.
8 8
9This is deliberately simpler than full Hermes Box: there are no release locks,9**Status: experiment, mid-transition.** The bash prototypes (smolvm `./box`
10offline artifact closures, or transactional component updates. The reliability10and the compose `./docker/boxd`) have been retired in favor of a Go CLI
11boundary is narrower: safe lifecycle operations and verified state backups.11(`tx9`, this repo, embedding its provisioning and guest assets) that is now
12implemented and under active development — still experimental, not yet
13released. See [docs/tx9-cli-design.md](docs/tx9-cli-design.md) for the full
14design and [docs/docker-architecture.md](docs/docker-architecture.md) for
15the runtime architecture and its verification history.
12 16
13## Model17## Model
14 18
15```text19```text
16./box new alpha20tx9 create (Go CLI, embedded build assets)
17 │ Ubuntu VM + pinned Hermes runtime
18 ▼21 ▼
19 smolvm "alpha"22 compose-style project per box ─────────────────────────────────┐
20 │ managed tools: /opt/hermes-box and /usr/local23 │ ┌───────────────────────┐ ┌─────────────────────────┐ │
21 │ portable state: /data/home/agent24 │ │ agent ("linux land") │ │ executor │ │
22 │ .claude · .codex · .hermes · .executor · workspace · XDG state25 │ │ claude·codex·hermes │ │ Executor daemon only │ │
23 │26 │ │ gateway, sudo, free │ │ dashboard / + MCP /mcp │ │
24 │ ./box save alpha27 │ │ 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 └──────────────────────────────────────────────────────────────┘
25 ▼33 ▼
26 backups/alpha-<timestamp>.tar.gz.gpg34 <box>-<timestamp>.tx9 encrypted, validated state archive
27 encrypted, validated state archive plus runtime-version manifest
28```35```
29 36
30The VM protects the host filesystem. Networking is always enabled because37The agent volume is the box; containers are disposable; tools are image
31provisioning and the agent tools require internet access. Tools inside one box38layers ("upgrade" = rebuild image + recreate containers). Executor state is
32share the `agent` account, credentials, network, and passwordless guest sudo;39scratch and does not travel. Networking is always enabled because the agent
33they are one trust domain.40tools require internet access.
34
35`/data` is an organizational boundary on the VM's persistent root overlay, not
36a separate virtual disk. Deleting or losing the VM loses live state, so keep
37current backups for anything important.
38
39## Quick start
40
41Host prerequisites:
42 41
43- macOS with the smolvm-supported virtualization stack, or Linux on x86_64 or42## Repository layout
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.
50 43
51On Linux, verify KVM access before creating a box:44| Path | Role |
52 45|---|---|
53```bash46| `box.env` | Build/runtime configuration: tool pins, Hermes installer SHA pin, feature flags |
54test -r /dev/kvm -a -w /dev/kvm47| `provision/provision.sh` | Runs inside `docker build` (`tools` + `assets` layers); installs everything |
55smolvm machine ls48| `guest/` | In-box runtime: `hb` control CLI, `hb-workload` reconcile loop, `hermes-state` validator, profiles, tmux config |
56```49| `docker/` | Image build assets: `Dockerfile`, agent + executor entrypoints |
57 50| `docs/` | Architecture, CLI design, operations history |
58```bash51| `tests/` | Static contract checks and guest-script regressions (`make check`) |
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
75Set `BOX_PASSPHRASE` for unattended encryption/decryption. Otherwise GPG
76passphrases are read from the terminal without echo.
77 52
78## Commands53## In-box control (`hb`)
79 54
80| Command | Behavior |55Inside the agent container:
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
95Mutating commands use local lock directories so concurrent operations cannot
96modify the same box or registry entry.
97
98## Executor and MCP
99
100Executor runs continuously on guest `127.0.0.1:4788`. If web exposure is
101enabled, 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
103authenticated 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
107or Tailscale rather than at its console, that handoff can report success
108without any browser becoming visible to you — these openers consider the job
109done once they hand the URL to a desktop session or portal, not once a human
110sees a window. To reach the UI from a separate client machine instead, forward
111the host-loopback port yourself, e.g. `ssh -L <port>:127.0.0.1:<port> <host>`,
112read the live token from inside the box with `./box enter` then `hb
113executor-token` (so it never touches host logs, shell history, or `box`'s own
114output), and open `http://localhost:<port>/?_token=<token>` in a browser on
115that client machine.
116
117The workload keeps one Executor daemon alive. Claude and Codex connect to that
118daemon's authenticated Streamable HTTP endpoint; they do not launch `executor
119mcp` as a second process. The bearer token remains under `/data` in Executor's
120mode-0600 auth file and a mode-0600 Codex environment file. Set
121`WIRE_EXECUTOR_MCP=0` before provisioning to disable registration.
122
123The guest doctor check—and the host check when web exposure is enabled—performs
124a complete MCP handshake: it uses `jq` to validate the JSON `initialize`
125result and negotiated protocol version, sends the
126initialized notification, and closes the session best-effort. Requests have
127bounded timeouts. A listening socket, malformed result, 404 page, or HTTP 500
128response does not count as healthy. Without an exposed port, doctor explicitly
129reports that the host endpoint check was skipped.
130
131Hermes runs as a supervised foreground gateway alongside Executor. Fresh and
132imported boxes begin with the gateway durably disabled, so setup and migration
133cannot accidentally create a second Discord writer. Enable it explicitly only
134after authentication or the final single-writer cutover. Inside a box:
135 56
136```bash57```bash
137hb status58hb status / hb doctor / hb versions
138hb doctor59hb down # durable pause; the reconcile loop won't restart services
139hb versions
140hb down # durable pause; the workload will not immediately restart it
141hb up60hb up
142hb acknowledge-active-paths
143hb cutover-ready
144hb gateway-enable --confirm-single-writer I_CONFIRM_NO_OTHER_GATEWAY_USES_THIS_IDENTITY61hb gateway-enable --confirm-single-writer I_CONFIRM_NO_OTHER_GATEWAY_USES_THIS_IDENTITY
145hb gateway-disable62hb gateway-disable
146hb verify-state63hb verify-state
147hb wire-mcp # replace stale/legacy wiring with authenticated HTTP MCP64hb wire-mcp # (re)register Executor's authenticated HTTP MCP for claude+codex
148hb logs executor65hb logs executor
149```66```
150 67
151## Backup and restore guarantees68Fresh and restored boxes begin with the Hermes gateway durably disabled so
152 69setup and migration cannot create a second Discord writer; enabling it is
153`box save`:70always an explicit, confirmed step. Executor runs remotely from the agent's
154 71perspective (`EXECUTOR_HOST=executor`): `hb` checks reachability and wires
1551. acquires the box mutation lock;72MCP with the injected bearer token; it never spawns or stops the daemon.
1562. gracefully quiesces Hermes and Executor, waits for shutdown, checkpoints73
157 SQLite, and runs integrity checks;74## Backup guarantees
1583. atomically writes a mode-0600 runtime-version manifest at75
159 `~/.config/hermes-box/runtime-manifest` and archives `/data` inside the guest;76Saves quiesce the gateway, checkpoint SQLite, archive agent `/data` (minus
1604. excludes caches, native dependency trees, checkpointed Hermes SQLite77caches, PIDs, locks, and DB sidecars), validate the archive member-by-member
161 sidecars, PIDs, and locks while preserving unrelated `/data` database files,78(absolute paths, traversal, special files, duplicate members, unsafe
162 then copies the archive using `smolvm machine cp`;79symlink/hardlink targets, link cycles), encrypt with GPG AES-256, then
1635. validates its paths and required durable-home layout;80decrypt-verify before publishing without overwriting. Restores validate
1646. encrypts to a unique temporary destination;81before creating anything, stage-then-promote inside a throwaway container,
1657. decrypts and lists the encrypted result as a verification pass; and82re-arm the quiesce and gateway-disable markers, mint a fresh executor token,
1668. publishes it without overwriting an existing backup.83and rewire MCP automatically.
167
168Host and guest plaintext temporaries and operation locks are removed by exit
169and signal traps. Guest plaintext is removed before services are selectively
170resumed to their prior state, even
171when a later save step fails. Do not actively edit workspace files during a
172save; Executor is quiesced, but arbitrary editor or agent processes are not
173frozen.
174
175`box load` decrypts and validates before spending time or creating resources.
176It restores while Executor is paused, removes hidden as well as normal old
177state into staging, verifies databases before the swap, refreshes
178repository-owned guest assets, keeps the Hermes gateway disabled, starts a fresh
179Executor daemon, repairs MCP wiring, and runs the doctor. A failed new or
180load operation—including interruption by HUP, INT, or TERM—attempts to remove
181only an incomplete destination VM proven to have been created by that
182invocation. Registry metadata is removed only after VM absence is verified. If
183cleanup cannot delete the VM or acquire the registry lock, it preserves
184metadata and prints recovery guidance. An unmanaged or ambiguous same-named
185smolvm machine is never deleted.
186
187Backups are state-portable, not VM images. Hermes itself is pinned by full Git
188SHA and rebuilt for the guest architecture. Ubuntu, Claude, Codex, and Executor
189packages can still drift; the runtime manifest records their versions.
190
191Native `hermes backup` imports add ZIP traversal/symlink validation, staging and
192duplicate normalized-path rejection, atomic swap, SQLite integrity and inventory
193baselines, and active-file-only path
194rewrites. Historical sessions, memories, databases, and logs are not rewritten.
195Validation also requires an importable canonical root Hermes marker after all
196skip rules, so a transient-only archive cannot replace existing state with an
197empty home.
198The 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
200a structurally valid universal Mach-O header are deterministically skipped
201before staging, including beneath approved external provider trees. The fat
202header validation distinguishes the shared `CAFEBABE` prefix from portable Java
203class files. A successful import with exclusions emits a warning and records
204exact skipped paths, byte counts, reasons, and normalized file/byte totals in
205the import manifest; it does not silently treat macOS binaries as portable.
206External provider state is previewed and rejected unless the operator explicitly
207approves every safe top-level destination with repeatable `--external NAME`
208flags; reserved destinations such as `.ssh`, `.config`, and shell startup files
209are never imported. Ordinary supplemental trees remain separate.
210See [Nexus operations and Eventide migration](docs/nexus-operations.md) for the
211host 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
223Configuration lives in `box.env`. The provisioning script installs a root-owned
224copy at `/etc/hermes-box.env`, so guest helpers honor `INSTALL_HERMES`,
225`INSTALL_EXECUTOR`, `EXECUTOR_PORT`, and `WIRE_EXECUTOR_MCP` consistently.
226New boxes default to 4 CPUs, 8 GiB memory, and a configurable 64 GiB overlay.
227Optional Hermes API forwarding is disabled by default and uses per-box,
228collision-checked loopback ports when enabled. Registry rows remain compatible
229with legacy two-column entries.
230 84
231## Local validation85## Local validation
232 86
233```bash87```bash
234make check88make check # syntax + shellcheck + static contracts + regressions; hermetic
89make check-hermes-pin # network: compare HERMES_INSTALLER_SHA256 against upstream
235```90```
236 91
237This requires ShellCheck, Python 3, and jq, and runs Bash syntax checks, static92`.depot/workflows/check.yml` runs `make check` on every push and pull request
238contract checks, protocol-health regressions, and local archive/lifecycle smoke93to `main` via Depot CI.
239tests. It does not create a VM or contact upstream installers. `make check` is94
240fully hermetic, so `.depot/workflows/check.yml` runs it on every push and95If an image build fails with "Hermes installer checksum verification failed",
241pull request to `main` via Depot CI.96upstream rotated `install.sh` since the pin: run `make check-hermes-pin`,
242 97review the new installer, then update `box.env` yourself.
243If `./box new` fails immediately with "Hermes installer checksum verification
244failed", upstream has rotated `install.sh` since `HERMES_INSTALLER_SHA256` was
245last pinned. Run `make check-hermes-pin` (needs network access, so it's not
246part of `make check`) to confirm the drift, review the new installer, then
247update `box.env` yourself — this check does not auto-update the pin.
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.
13package main
14
15import "embed"
16
17//go:embed provision guest docker/Dockerfile docker/entrypoint.sh docker/executor-entrypoint.sh box.env
18var 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.
4set -euo pipefail
5
6ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7cd "$ROOT"
8# shellcheck source=box.env
9source "$ROOT/box.env"
10# shellcheck source=guest/lib-mcp.sh
11source "$ROOT/guest/lib-mcp.sh"
12
13REG="$ROOT/.boxes"
14LOCK_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
27die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
28say() { printf '\033[36m==>\033[0m %s\n' "$*"; }
29warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; }
30have() { 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
300gpg_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
306gpg_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'
313import sys
314import tarfile
315from pathlib import PurePosixPath
316
317def 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
321def 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
328def 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
341try:
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")
424except (OSError, tarfile.TarError, ValueError) as error:
425 print(f"unsafe archive: {error}", file=sys.stderr)
426 raise SystemExit(1)
427PY
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
604cmd_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
638cmd_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
646cmd_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
695cmd_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
786cmd_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
884cmd_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
904cmd_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
926cmd_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
965cmd_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
1004cmd_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
1024cmd_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
1032cmd_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
1040cmd_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
1060usage() {
1061 cat <<'EOF'
1062hermes-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.
1085EOF
1086}
1087
1088main() {
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
1109if [[ "${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
16FROM ubuntu:24.04
17
18SHELL ["/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.
24COPY box.env /tmp/ctx/box.env
25COPY provision/ /tmp/ctx/provision/
26RUN bash /tmp/ctx/provision/provision.sh tools
27
28COPY guest/ /tmp/ctx/guest/
29RUN bash /tmp/ctx/provision/provision.sh assets && rm -rf /tmp/ctx
30
31COPY docker/entrypoint.sh /opt/hermes-box/bin/entrypoint
32COPY docker/executor-entrypoint.sh /opt/hermes-box/bin/executor-entrypoint
33RUN 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.
36VOLUME /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.
41EXPOSE 14788 18642
42
43ENTRYPOINT ["/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.
7set -uo pipefail
8
9trap 'exit 143' TERM
10trap '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
18EXEC_BRIDGE_PORT="${BOX_EXECUTOR_BRIDGE_PORT:-14788}"
19API_BRIDGE_PORT="${BOX_HERMES_BRIDGE_PORT:-18642}"
20
21while 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
25done
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.
10set -euo pipefail
11
12[[ -n "${EXECUTOR_MCP_TOKEN:-}" ]] || { echo "EXECUTOR_MCP_TOKEN is required" >&2; exit 1; }
13
14mkdir -p /data/home/agent /data/logs
15chown agent:agent /data/home/agent /data/logs
16
17# shellcheck disable=SC2016 # $EXECUTOR_MCP_TOKEN expands in the inner shell
18exec 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
3Status: architecture record, 2026-07-03. The `boxd` bash prototype referenced
4throughout was verified against this design and then **deleted** (2026-07-04)
5in favor of the Go CLI specified in [tx9-cli-design.md](tx9-cli-design.md);
6recover it from git history (`6f1ced3` and earlier) when porting logic. The
7runtime topology, contracts, and verification results below remain the
8authoritative description of what the Go CLI must create.
9
10## Why move off smolvm
11
12smolvm gives real VM isolation, but in practice it has been the least
13reliable part of the system: bespoke lifecycle semantics, the 4 GiB
14pack-export cap that forces overlay-slimming gymnastics, provisioning that
15streams a tarball into a live VM on **every** `box new` (minutes, network
16required, can fail halfway and needs rollback machinery), and `machine exec`
17quirks that shaped a lot of defensive code in `./box`.
18
19Docker 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
34One box = one Docker Compose project (`hbl-<name>`) with **two containers on
35a private network**. This split exists because the main security question is
36Executor: the agent (Hermes, claude, codex) should be free to do whatever it
37wants in its own container, and Executor — the thing that holds real
38credentials and takes real actions — should not share a filesystem, process
39table, 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
74Containers are disposable; the `agent-data` volume is the box. The executor
75container's volume is scratch — its identity is the bearer token boxd mints
76per 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`
78with that token (`EXECUTOR_HOST` + `BOXD_EXECUTOR_TOKEN`); the same token
79authenticates you to the dashboard/MCP from the LAN or Tailscale.
80
81What the agent container can do to Executor: exactly what any LAN client can
82do — talk HTTP to the token-gated endpoint. Nothing else. Cross-box: each
83compose 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
110Roughly: `./box` is ~1100 lines; `boxd` is ~300, and most of the deleted 800
111lines were compensation for smolvm's sharp edges.
112
113## The trade: isolation
114
115Decision (2026-07-03): VM isolation is a nice-to-have; containers are enough
116for now, and the agent keeps passwordless sudo inside its container. The
117security question that actually matters is **Executor**, and the compose
118split 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
132gVisor (`--runtime=runsc`) remains the one-flag escape hatch if VM-grade
133isolation is ever wanted back — the design doesn't change.
134
135Networking stance is unchanged from smolvm (always-on, tools need internet).
136The executor's single published port binds 0.0.0.0 (LAN + Tailscale, per
137decision); 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
152The "repair becomes replace" row is the philosophical center: containers are
153cattle, volumes are pets. Any doubt about a box's runtime → delete the
154container, `boxd new`-style recreate against the same volume. There is no
155drift 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
2231. Land `docker/` alongside the smolvm path (no removals), mark experimental.
2242. 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.
2263. 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.
2294. 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
3Status: agreed 2026-07-04 (12 decisions below, settled in conversation with
4Davis). Nothing here is implemented yet; this document is the spec the Go
5implementation is built from. The bash prototypes that informed it (`./box`
6on smolvm, `./docker/boxd` on compose) are deleted; recover them from git
7history (`9350212` and earlier) when porting logic.
8
9## What tx9 is
10
11A single Go binary installed on a machine ("tx9") that creates and manages
12hermes 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
15source.
16
17```
18curl -fsSL https://tx9.davis7.sh/install | bash
19tx9 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
57These were dry-run- and QA-verified in the boxd prototype and must survive
58the 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
122The guest layer (`guest/`, `provision/`, `box.env`) is shared and already
123compose-split aware, so existing boxd-created boxes are structurally
124identical to what tx9 will create — only labels/names differ
125(`hbl-*` vs `tx9-*`). Don't build compatibility: the running dry boxes are
126disposable, and any box that matters moves via `backup`/`import` anyway.
go.mod+38−0
@@ -0,0 +1,38 @@
1module github.com/davis7dotsh/tx9
2
3go 1.26
4
5require (
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
11require (
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 @@
1github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
2github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
3github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
4github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
5github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
6github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
7github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
8github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
9github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
10github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
11github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
12github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
13github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
14github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
15github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g=
16github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
17github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
18github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
19github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
20github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
21github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
22github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
23github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
24github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
25github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
26github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
27github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
28github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
29github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
30github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
31github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
32github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
33github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
34github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
35github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
36github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
37github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
38github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
39github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
40github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
41github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
42github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
43github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
44github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
45github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
46github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
47github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
48github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
49github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
50github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
51github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
52github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
53github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
54github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
55github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
56github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
57github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
58github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
59github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
60github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
61github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
62github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
63github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
64github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
65github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
66github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
67github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
68github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
69go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
70go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
71go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
72go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
73go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
74go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
75go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
76go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
77go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
78go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
79go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
80go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
81go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
82go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
83go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
84go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
85go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
86go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
87go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
88go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
89golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
90golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
91golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
92golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
93golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
94golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
95golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
96golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
97golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
98golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
99golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
100google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
101google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
102google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
103google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
104google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
105google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
106google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
107google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
108gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
109gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
110gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
111gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
112gotest.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
22TOKEN_FILE="$AGENT_HOME/.executor/server-control/auth.json"22TOKEN_FILE="$AGENT_HOME/.executor/server-control/auth.json"
23TOKEN_ENV="$AGENT_HOME/.config/hermes-box/executor-mcp.env"23TOKEN_ENV="$AGENT_HOME/.config/hermes-box/executor-mcp.env"
24 24
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.
30EXECUTOR_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
25init() {34init() {
26 local managed=(35 local managed=(
27 "$AGENT_HOME" "$AGENT_HOME/.claude" "$AGENT_HOME/.codex" "$AGENT_HOME/.hermes"36 "$AGENT_HOME" "$AGENT_HOME/.claude" "$AGENT_HOME/.codex" "$AGENT_HOME/.hermes"
@@ -44,7 +53,7 @@ init() { · 16 unmodified lines
44}53}
45 54
46_port_open() {55_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; }
48 return 157 return 1
49}58}
50 59
@@ -119,6 +128,11 @@ _spawn_without_lock_fd() { · 68 unmodified lines
119 128
120_executor_up() {129_executor_up() {
121 init130 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
122 command -v executor >/dev/null 2>&1 || {136 command -v executor >/dev/null 2>&1 || {
123 [[ "${INSTALL_EXECUTOR:-0}" == 1 ]] && { echo "executor not installed" >&2; return 1; }137 [[ "${INSTALL_EXECUTOR:-0}" == 1 ]] && { echo "executor not installed" >&2; return 1; }
124 echo "executor disabled"138 echo "executor disabled"
@@ -185,6 +199,9 @@ reconcile() { · 60 unmodified lines
185}199}
186 200
187_stop_executor() {201_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
188 command -v executor >/dev/null 2>&1 && executor daemon stop >/dev/null 2>&1 || true205 command -v executor >/dev/null 2>&1 && executor daemon stop >/dev/null 2>&1 || true
189 pkill -f "executor daemon run" 2>/dev/null || true206 pkill -f "executor daemon run" 2>/dev/null || true
190 rm -f "$AGENT_HOME/.executor"/daemon-active-* 2>/dev/null || true207 rm -f "$AGENT_HOME/.executor"/daemon-active-* 2>/dev/null || true
@@ -302,8 +319,18 @@ web() { · 111 unmodified lines
302}319}
303 320
304executor_token() {321executor_token() {
305 [[ -r "$TOKEN_FILE" ]] || return 1322 # BOXD_EXECUTOR_TOKEN is the runtime-injected token in the compose split.
306 sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TOKEN_FILE" | head -1323 # 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
307}334}
308 335
309_write_token_env() {336_write_token_env() {
@@ -335,9 +362,10 @@ wire_mcp() { · 25 unmodified lines
335 rm -f "$WIRED" "$TOKEN_ENV"362 rm -f "$WIRED" "$TOKEN_ENV"
336 return 0363 return 0
337 fi364 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; }
339 up >/dev/null366 up >/dev/null
340 local token url="http://127.0.0.1:${EXECUTOR_PORT:-4788}/mcp" failed=0367 local token url failed=0
368 url="$(_executor_url)"
341 token="$(executor_token)"369 token="$(executor_token)"
342 [[ -n "$token" ]] || { echo "executor token unavailable" >&2; return 1; }370 [[ -n "$token" ]] || { echo "executor token unavailable" >&2; return 1; }
343 _write_token_env "$token"371 _write_token_env "$token"
@@ -362,11 +390,16 @@ wire_mcp() { · 18 unmodified lines
362}390}
363 391
364wire_once() {392wire_once() {
365 local token393 local token url
366 if [[ "${WIRE_EXECUTOR_MCP:-0}" != 1 ]]; then wire_mcp; return; fi394 if [[ "${WIRE_EXECUTOR_MCP:-0}" != 1 ]]; then wire_mcp; return; fi
367 token="$(executor_token 2>/dev/null || true)"395 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).
368 if [[ -f "$WIRED" && "$(cat "$WIRED")" == "$WIRE_VERSION" && -r "$TOKEN_ENV" && -n "$token" ]] &&400 if [[ -f "$WIRED" && "$(cat "$WIRED")" == "$WIRE_VERSION" && -r "$TOKEN_ENV" && -n "$token" ]] &&
369 grep -Fq "$token" "$TOKEN_ENV"; then return 0; fi401 grep -Fq "$token" "$TOKEN_ENV" &&
402 grep -qF "url = \"$url\"" "$CODEX_HOME/config.toml" 2>/dev/null; then return 0; fi
370 wire_mcp403 wire_mcp
371}404}
372 405
@@ -432,13 +465,13 @@ _command_exists() { command -v "$1" >/dev/null 2>&1; } · 59 unmodified lines
432_hermes_state_valid() { hermes-state verify >/dev/null; }465_hermes_state_valid() { hermes-state verify >/dev/null; }
433 466
434_claude_http_config() {467_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)"
436}469}
437 470
438_mcp_auth_works() {471_mcp_auth_works() {
439 local token472 local token
440 token="$(executor_token)" || return 1473 token="$(executor_token)" || return 1
441 _mcp_initialize "http://127.0.0.1:${EXECUTOR_PORT:-4788}/mcp" "$token"474 _mcp_initialize "$(_executor_url)" "$token"
442}475}
443 476
444doctor() {477doctor() {
@@ -453,7 +486,12 @@ doctor() { · 8 unmodified lines
453 else486 else
454 printf 'ok Hermes gateway intentionally disabled or quiesced\n'487 printf 'ok Hermes gateway intentionally disabled or quiesced\n'
455 fi488 fi
456 if [[ "${INSTALL_EXECUTOR:-0}" == 1 ]]; then489 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
457 _check "executor installed" _command_exists executor || failed=1495 _check "executor installed" _command_exists executor || failed=1
458 if [[ -e "$QUIESCE_FILE" ]]; then496 if [[ -e "$QUIESCE_FILE" ]]; then
459 _check "Executor target port closed while quiesced" _port_closed || failed=1497 _check "Executor target port closed while quiesced" _port_closed || failed=1
@@ -473,7 +511,7 @@ doctor() { · 13 unmodified lines
473 _check "MCP wiring version" test "$(cat "$WIRED" 2>/dev/null)" = "$WIRE_VERSION" || failed=1511 _check "MCP wiring version" test "$(cat "$WIRED" 2>/dev/null)" = "$WIRE_VERSION" || failed=1
474 _check "MCP token environment" test -r "$TOKEN_ENV" || failed=1512 _check "MCP token environment" test -r "$TOKEN_ENV" || failed=1
475 _check "Claude HTTP MCP config" _claude_http_config || failed=1513 _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=1514 _check "Codex HTTP MCP config" grep -qF "url = \"$(_executor_url)\"" "$CODEX_HOME/config.toml" || failed=1
477 fi515 fi
478 [[ "$failed" == 0 ]]516 [[ "$failed" == 0 ]]
479}517}
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.
20package archive
21
22import (
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.
34const FormatVersion = 1
35
36// Tar member names inside a .tx9 container, in fixed order.
37const (
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.
45type 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.
56func 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.
68func 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.
126func 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.
153func 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.
171func 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 @@
1package archive
2
3import (
4 "archive/tar"
5 "bytes"
6 "io"
7 "os"
8 "path/filepath"
9 "testing"
10 "time"
11)
12
13func tarWriterForTest(t *testing.T, w io.Writer) *tar.Writer {
14 t.Helper()
15 return tar.NewWriter(w)
16}
17
18func 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
33func 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
90func 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
131func 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.
146func 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 @@
1package archive
2
3import (
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.
22func 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.
63func 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
70func 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.
79func 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.
95func 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.
103func 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.
117func 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.
135func 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.
175func 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.
185func 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 @@
1package archive
2
3import (
4 "bytes"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12func 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
19func 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
60func 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.
91func 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
121func 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 @@
1package archive
2
3import (
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.
30var maxArchiveMembers = 1_000_000
31
32func 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).
199type 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).
212type 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.
219func 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.
228func 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.
244func 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).
258func 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.
281func 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.
309func 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.
321func (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.
400func 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 @@
1package archive
2
3import (
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.
14type testEntry struct {
15 Name string
16 Typeflag byte
17 Linkname string
18 Body string
19}
20
21func 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
56func dir(name string) testEntry { return testEntry{Name: name, Typeflag: tar.TypeDir} }
57
58func file(name, body string) testEntry {
59 return testEntry{Name: name, Typeflag: tar.TypeReg, Body: body}
60}
61
62func symlink(name, target string) testEntry {
63 return testEntry{Name: name, Typeflag: tar.TypeSymlink, Linkname: target}
64}
65
66func 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).
72func validBase() []testEntry {
73 return []testEntry{
74 dir(""),
75 dir("home"),
76 dir("home/agent"),
77 }
78}
79
80func runValidate(t *testing.T, entries []testEntry) error {
81 t.Helper()
82 data := buildTarGz(t, entries)
83 return ValidateDataTar(bytes.NewReader(data))
84}
85
86func 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
97func 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.
106func 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
285func 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.
297func 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.
311func 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
329func 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.
6package assets
7
8import (
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.
20const 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.
31func 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/).
87func 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.
101func 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 @@
1package assets
2
3import (
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).
15func 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
25func 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.
8package box
9
10import (
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.
27var 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).
31const 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).
36type 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).
47func (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".
65func 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>".
70func 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).
77func 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).
83func 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.
125func 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.
140func 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).
153func 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).
168func 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.
183func 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).
204func 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).
223func 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.
236func 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.
243func 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).
250func 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).
265func 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 @@
1package box
2
3import "testing"
4
5func 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
15func TestNetworkName(t *testing.T) {
16 if got := NetworkName("dry1"); got != "tx9-dry1" {
17 t.Errorf("NetworkName = %q, want tx9-dry1", got)
18 }
19}
20
21func 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
31func 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
78func 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
85func 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
93func 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
100func 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 @@
1package box
2
3import (
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.
23var 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.
28const (
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.
36type 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.
49func 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).
144func 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`.
180func 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.
217func 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 @@
1package cli
2
3import (
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.
20func 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).
54func 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.
63func 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.
77func 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.
95func 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 @@
1package cli
2
3import (
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.
12func 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 @@
1package cli
2
3import (
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.
34var 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.
59func 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.
75func 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).
231func 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.
244func 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.
257func 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.
277func 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.
309func 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 @@
1package cli
2
3import (
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).
21func 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 @@
1package cli
2
3import (
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).
17func 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).
52func 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 @@
1package cli
2
3import (
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.
18func 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).
71func 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 @@
1package cli
2
3import (
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.
26func 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 @@
1package cli
2
3import (
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.
28const restoreStageScript = `set -euo pipefail
29find /data -mindepth 1 -delete
30stage=/data/.hb-restore-stage
31mkdir -m 0700 "$stage"
32tar xzf - --no-same-owner -C "$stage"
33test -d "$stage/home/agent"
34chown root:root "$stage" "$stage/home"
35chmod 0711 "$stage" "$stage/home"
36chown -R agent:agent "$stage/home/agent" "$stage/logs" 2>/dev/null || true
37mkdir -p "$stage/home/agent/.config/hermes-box"
38touch "$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"
41chown -R agent:agent "$stage/home/agent/.config/hermes-box"
42runuser -u agent -- env HOME=/data/home/agent \
43 /opt/hermes-box/bin/hermes-state verify --home "$stage/home/agent/.hermes" >/dev/null
44shopt -s dotglob
45mv "$stage"/* /data/
46rmdir "$stage"
47chown 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).
58func 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 @@
1package cli
2
3import (
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).
18func 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).
55func 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 @@
1package cli
2
3import (
4 "testing"
5
6 "github.com/davis7dotsh/tx9/internal/version"
7)
8
9func 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 @@
1package cli
2
3import (
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.
18func 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
63func 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 @@
1package cli
2
3import (
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.
24func 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.
59func 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.
113func 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 @@
1package cli
2
3import (
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.
14func 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.
39func 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 @@
1package cli
2
3import (
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.
28func 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.
88func 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.
115func 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
132func 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.
5package cli
6
7import (
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.
20var 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).
25type commandFunc func(args []string) error
26
27// commands maps canonical subcommand names to their implementation.
28// Registered in registerCommands below.
29var 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).
34var 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).
41var 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).
58var aliases = map[string]string{
59 "ssh": "enter",
60 "export": "backup",
61}
62
63func 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
78func 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.
85func 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
123func 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.
6package docker
7
8import (
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.
29type 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.
36func 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.
49func (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.
55func (c *Client) Raw() *client.Client {
56 return c.cli
57}
58
59// ImageExists reports whether tag is present in the local image store.
60func (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.
74func (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.
94func (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).
106func (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.
120func (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.
128func (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.
137func (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.
147type 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).
167func (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.
208func (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).
217func (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.
225func (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.
233func (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).
243func (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.
253func (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.
263func (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.
271type 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.
284func (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 @@
1package docker
2
3import (
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.
18func (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.
48type 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.
67func (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 @@
1package 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).
5const (
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.
13const (
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).
21func 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).
3package lock
4
5import (
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.
17func 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
40func 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").
2package names
3
4import (
5 "fmt"
6 "math/rand/v2"
7 "regexp"
8)
9
10var 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
18var 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.
28var 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
36var validPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
37
38const 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.
51func 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.
89func 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 @@
1package names
2
3import "testing"
4
5func 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.
29func 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
46func 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 @@
1package selfupdate
2
3import "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.
10func 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 @@
1package selfupdate
2
3import "testing"
4
5func 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 @@
1package selfupdate
2
3import (
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.
15func 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
47func 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 @@
1package selfupdate
2
3import (
4 "strings"
5 "testing"
6)
7
8func 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
31func 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
42func 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
51func 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
60func 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 @@
1package selfupdate
2
3import (
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.
13const 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.
20var ErrNoRelease = fmt.Errorf("no releases published yet")
21
22// Asset is one file attached to a GitHub release.
23type 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.
30type 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.
36func (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
45func (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.
59func 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.
13package selfupdate
14
15import (
16 "crypto/sha256"
17 "encoding/hex"
18 "fmt"
19 "io"
20 "net/http"
21 "os"
22 "path/filepath"
23 "runtime"
24 "time"
25)
26
27const (
28 checksumsAssetName = "checksums.txt"
29 userAgent = "tx9-selfupdate"
30)
31
32// Options configures Update.
33type 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.
56type 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.
73var 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.
76func 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.
187func 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.
209func 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 @@
1package selfupdate
2
3import (
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.
19func 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.
54type rewriteTransport struct {
55 target string
56 rt http.RoundTripper
57}
58
59func (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
67func clientFor(srv *httptest.Server) *http.Client {
68 return &http.Client{Transport: rewriteTransport{target: srv.URL, rt: http.DefaultTransport}}
69}
70
71func 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
87func 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
94func 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
144func 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
201func 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 @@
1package selfupdate
2
3import (
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.
10func 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.
20func 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.
29func 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.
59func 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 @@
1package selfupdate
2
3import "testing"
4
5func 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
29func 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
40func 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).
2package state
3
4import (
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.
14func 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.
27func 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.
40func 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.
53func 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.
62func 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.
72func 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.
107func 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.
129func 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.
2package token
3
4import (
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).
13func 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 @@
1package token
2
3import "testing"
4
5func 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.
2package version
3
4// Version is set via -ldflags at build time (see Makefile). Defaults to
5// "dev" for `go run`/unversioned builds.
6var Version = "dev"
main.go+12−0
@@ -0,0 +1,12 @@
1// Command tx9 creates and manages hermes boxes (see docs/tx9-cli-design.md).
2package main
3
4import (
5 "os"
6
7 "github.com/davis7dotsh/tx9/internal/cli"
8)
9
10func 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]
2Description=Automount CIFS backup vault for tx9
3
4[Automount]
5Where=/mnt/davis-vault/tx9
6TimeoutIdleSec=600
7
8[Install]
9WantedBy=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]
2Description=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.
7What=//nas.example.invalid/share
8Where=/mnt/davis-vault/tx9
9Type=cifs
10Options=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]
2Description=Prune retained backups for tx9 agent box %i
3After=tx9-backup@%i.service
4
5[Service]
6Type=oneshot
7User=tx9
8Group=tx9
9UMask=0077
10NoNewPrivileges=true
11ProtectHome=true
12ProtectSystem=strict
13PrivateTmp=true
14ReadWritePaths=/opt/tx9 /var/backups/tx9 /var/lib/tx9 /etc/tx9
15ExecStart=/opt/tx9/ops/tx9-backup-prune %i
16TimeoutStartSec=5min
ops/systemd/tx9-backup-prune@.timer+0−10
@@ -1,10 +0,0 @@
1[Unit]
2Description=Scheduled backup retention prune for tx9 agent box %i
3
4[Timer]
5OnCalendar=*-*-* 04:00:00
6RandomizedDelaySec=30min
7Persistent=true
8
9[Install]
10WantedBy=timers.target
ops/systemd/tx9-backup@.service+0−18
@@ -1,18 +0,0 @@
1[Unit]
2Description=Encrypted backup for tx9 agent box %i
3After=tx9-box@%i.service
4
5[Service]
6Type=oneshot
7User=tx9
8Group=tx9
9SupplementaryGroups=kvm
10UMask=0077
11NoNewPrivileges=true
12ProtectHome=true
13ProtectSystem=strict
14PrivateTmp=true
15ReadWritePaths=/opt/tx9 /var/backups/tx9 /var/lib/tx9 /etc/tx9
16LoadCredential=backup-passphrase:/etc/tx9/backup-passphrase
17ExecStart=/opt/tx9/ops/tx9-host backup %i
18TimeoutStartSec=2h
ops/systemd/tx9-backup@.timer+0−10
@@ -1,10 +0,0 @@
1[Unit]
2Description=Scheduled encrypted backup for tx9 agent box %i
3
4[Timer]
5OnCalendar=*-*-* 03:15:00
6RandomizedDelaySec=30min
7Persistent=true
8
9[Install]
10WantedBy=timers.target
ops/systemd/tx9-box@.service+0−26
@@ -1,26 +0,0 @@
1[Unit]
2Description=tx9 agent box %i
3After=network-online.target
4Wants=network-online.target
5
6[Service]
7Type=simple
8User=tx9
9Group=tx9
10SupplementaryGroups=kvm
11UMask=0077
12NoNewPrivileges=true
13ProtectHome=true
14ProtectSystem=strict
15PrivateTmp=true
16ReadWritePaths=/opt/tx9 /var/backups/tx9 /var/lib/tx9 /etc/tx9
17ExecStart=/opt/tx9/ops/tx9-host supervise %i
18ExecStop=-/bin/kill -TERM $MAINPID
19ExecStop=/opt/tx9/ops/tx9-host stop %i
20Restart=on-failure
21RestartSec=5s
22TimeoutStartSec=10min
23TimeoutStopSec=2min
24
25[Install]
26WantedBy=multi-user.target
ops/systemd/tx9-health@.service+0−17
@@ -1,17 +0,0 @@
1[Unit]
2Description=Verify tx9 agent box %i
3After=tx9-box@%i.service
4
5[Service]
6Type=oneshot
7User=tx9
8Group=tx9
9SupplementaryGroups=kvm
10UMask=0077
11NoNewPrivileges=true
12ProtectHome=true
13ProtectSystem=strict
14PrivateTmp=true
15ReadWritePaths=/opt/tx9 /var/backups/tx9 /var/lib/tx9 /etc/tx9
16ExecStart=/opt/tx9/ops/tx9-host health %i
17TimeoutStartSec=3min
ops/systemd/tx9-health@.timer+0−10
@@ -1,10 +0,0 @@
1[Unit]
2Description=Periodic health check for tx9 agent box %i
3
4[Timer]
5OnBootSec=5min
6OnUnitActiveSec=5min
7Persistent=true
8
9[Install]
10WantedBy=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).
5set -euo pipefail
6
7requested_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}
12readonly requested_name
13
14TX9_CONFIG="${TX9_CONFIG:-/etc/tx9/tx9.conf}"
15[[ ! -r "$TX9_CONFIG" ]] || { # shellcheck disable=SC1090
16 source "$TX9_CONFIG"
17}
18box_config="${TX9_BOX_CONFIG_DIR:-/etc/tx9/boxes}/${requested_name}.conf"
19[[ ! -r "$box_config" ]] || { # shellcheck disable=SC1090
20 source "$box_config"
21}
22name="$requested_name"
23
24destination="${TX9_BACKUP_DIR:-/var/backups/tx9}"
25# Always keep at least this many of the newest backups, regardless of age.
26keep_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.
29keep_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
38prune_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
69prune_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
2set -euo pipefail
3
4requested_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}
9readonly requested_name
10
11TX9_CONFIG="${TX9_CONFIG:-/etc/tx9/tx9.conf}"
12[[ ! -r "$TX9_CONFIG" ]] || { # shellcheck disable=SC1090
13 source "$TX9_CONFIG"
14}
15box_config="${TX9_BOX_CONFIG_DIR:-/etc/tx9/boxes}/${requested_name}.conf"
16[[ ! -r "$box_config" ]] || { # shellcheck disable=SC1090
17 source "$box_config"
18}
19name="$requested_name"
20
21root="${TX9_ROOT:-/opt/tx9}"
22box="$root/box"
23[[ -x "$box" ]] || { echo "tx9 box CLI is not executable: $box" >&2; exit 1; }
24
25require_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
37case "${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 ;;
106esac
provision/provision.sh+16−0
@@ -331,9 +331,25 @@ main() { · 330 unmodified lines
331 PATH="$NODE_PREFIX/bin:$UV_DIR:$PATH" "$OPT/bin/hb" versions || true331 PATH="$NODE_PREFIX/bin:$UV_DIR:$PATH" "$OPT/bin/hb" versions || true
332}332}
333 333
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.
337tools_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
334if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then349if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
335 case "${1:-full}" in350 case "${1:-full}" in
336 full) main ;;351 full) main ;;
352 tools) tools_only ;;
337 assets) assets_only ;;353 assets) assets_only ;;
338 *) log "unknown provisioning mode: $1"; exit 2 ;;354 *) log "unknown provisioning mode: $1"; exit 2 ;;
339 esac355 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.
20set -eu
21
22REPO="davis7dotsh/tx9"
23INSTALL_DIR="${TX9_INSTALL_DIR:-$HOME/.local/bin}"
24
25log() {
26 printf 'tx9-install: %s\n' "$*" >&2
27}
28
29die() {
30 log "$*"
31 exit 1
32}
33
34need() {
35 command -v "$1" >/dev/null 2>&1 || die "'$1' is required to install tx9"
36}
37
38detect_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
46detect_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
54need curl
55need uname
56need mktemp
57need mkdir
58need chmod
59need mv
60need awk
61
62os=$(detect_os)
63arch=$(detect_arch)
64asset="tx9_${os}_${arch}"
65
66base_url="https://github.com/${REPO}/releases/latest/download"
67asset_url="${base_url}/${asset}"
68checksums_url="${base_url}/checksums.txt"
69
70tmpdir=$(mktemp -d)
71trap 'rm -rf "$tmpdir"' EXIT INT TERM HUP
72
73log "downloading ${asset}..."
74if ! curl -fsSL "$asset_url" -o "$tmpdir/$asset"; then
75 die "download failed: $asset_url (no release published yet, or an unsupported platform)"
76fi
77
78log "downloading checksums.txt..."
79if ! curl -fsSL "$checksums_url" -o "$tmpdir/checksums.txt"; then
80 die "download failed: $checksums_url"
81fi
82
83expected=$(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
86if command -v sha256sum >/dev/null 2>&1; then
87 actual=$(sha256sum "$tmpdir/$asset" | awk '{ print $1 }')
88elif command -v shasum >/dev/null 2>&1; then
89 actual=$(shasum -a 256 "$tmpdir/$asset" | awk '{ print $1 }')
90else
91 die "neither sha256sum nor shasum found; cannot verify ${asset} (install them, or download and verify manually)"
92fi
93
94[ "$actual" = "$expected" ] || die "checksum mismatch for ${asset}: got ${actual}, want ${expected} (not installing)"
95
96mkdir -p "$INSTALL_DIR"
97chmod 0755 "$tmpdir/$asset"
98mv "$tmpdir/$asset" "$INSTALL_DIR/tx9"
99
100log "installed tx9 to ${INSTALL_DIR}/tx9"
101
102case ":${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 ;;
108esac
109
110if version=$("$INSTALL_DIR/tx9" version 2>/dev/null); then
111 log "tx9 ${version} ready"
112else
113 log "installed, but 'tx9 version' did not run cleanly -- check ${INSTALL_DIR}/tx9 manually"
114fi
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).
6set -euo pipefail
7
8# shellcheck source=tests/lib.sh
9source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
10
11# --- box ls (empty registry) -------------------------------------------
12ls_empty_repo="$tmp/ls-empty-repo"
13make_repo "$ls_empty_repo"
14out="$(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) -
19ls_repo="$tmp/ls-repo"
20make_repo "$ls_repo"
21mkdir -p "$tmp/ls-state/present-box"
22printf 'present-box\t4790\t8650\t8650\nghost-box\t\t\t\n' >"$ls_repo/.boxes"
23out="$(PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/ls.calls" \
24 SMOLVM_STATE_DIR="$tmp/ls-state" "$ls_repo/box" ls)"
25grep -q '^BOX *STATE' <<<"$out"
26grep -q '^present-box *running *http://localhost:4790 *http://localhost:8650' <<<"$out"
27grep -q '^ghost-box *missing *— *—' <<<"$out"
28
29# --- box ls --all (needs the smolvm fixture's bare `machine ls`) -------
30mkdir -p "$tmp/ls-state/other-vm"
31out="$(PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/ls-all.calls" \
32 SMOLVM_STATE_DIR="$tmp/ls-state" "$ls_repo/box" ls --all)"
33grep -q 'present-box' <<<"$out"
34grep -q 'other-vm' <<<"$out"
35
36# --- box rm <name> --force ----------------------------------------------
37rm_repo="$tmp/rm-repo"
38make_repo "$rm_repo"
39mkdir -p "$tmp/rm-state/force-box"
40printf 'force-box\t4790\t\t\n' >"$rm_repo/.boxes"
41rm_sentinel="$tmp/rm-force.calls"
42out="$(PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$rm_sentinel" \
43 SMOLVM_STATE_DIR="$tmp/rm-state" "$rm_repo/box" rm force-box --force)"
44grep -q "deleted 'force-box'" <<<"$out"
45grep -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.
58if ! 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
60else
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"
74fi
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.
79rm_tty_repo="$tmp/rm-tty-repo"
80make_repo "$rm_tty_repo"
81mkdir -p "$tmp/rm-tty-state/tty-box"
82printf 'tty-box\t\t\t\n' >"$rm_tty_repo/.boxes"
83tty_sentinel="$tmp/rm-tty.calls"
84PROJECT_ROOT="$PROJECT_ROOT" RM_REPO="$rm_tty_repo" RM_STATE="$tmp/rm-tty-state" \
85 RM_SENTINEL="$tty_sentinel" python3 <<'PY'
86import os
87import pty
88import sys
89import time
90
91project_root = os.environ["PROJECT_ROOT"]
92repo = os.environ["RM_REPO"]
93state = os.environ["RM_STATE"]
94sentinel = os.environ["RM_SENTINEL"]
95
96
97def 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
127code, output = run("wrong-name")
128if code != 1 or b"deletion cancelled" not in output:
129 sys.exit(f"mismatched confirmation did not refuse deletion: code={code} output={output!r}")
130if os.path.exists(sentinel):
131 sys.exit("smolvm ran despite a confirmation mismatch")
132
133code, output = run("tty-box")
134if 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}")
136PY
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 ---------------
141stop_repo="$tmp/stop-repo"
142make_repo "$stop_repo"
143mkdir -p "$tmp/stop-state/stop-box"
144stop_sentinel="$tmp/stop.calls"
145PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$stop_sentinel" \
146 SMOLVM_STATE_DIR="$tmp/stop-state" "$stop_repo/box" stop stop-box >/dev/null
147grep -q -- 'machine stop --name stop-box' "$stop_sentinel"
148start_sentinel="$tmp/start.calls"
149PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$start_sentinel" \
150 SMOLVM_STATE_DIR="$tmp/stop-state" "$stop_repo/box" start stop-box >/dev/null
151grep -q -- 'machine start --name stop-box' "$start_sentinel"
152
153# --- box stop: a held per-box lock blocks a concurrent operation --------
154lock_repo="$tmp/lock-repo"
155make_repo "$lock_repo"
156mkdir -p "$tmp/lock-state/locked-box" "$lock_repo/.box-locks/locked-box.lock"
157printf '%s\n' "$$" >"$lock_repo/.box-locks/locked-box.lock/pid"
158locked_sentinel="$tmp/locked.calls"
159if 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
163fi
164grep -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 -------------------
168enter_repo="$tmp/enter-repo"
169make_repo "$enter_repo"
170mkdir -p "$tmp/enter-state/enter-box"
171enter_sentinel="$tmp/enter.calls"
172PATH="$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
175grep -q -- 'machine start --name enter-box' "$enter_sentinel"
176grep -q -- 'machine exec -it --name enter-box -- login -f agent' "$enter_sentinel"
177
178# --- _assign_port: the exhaustion path dies with the documented message -
179port_repo="$tmp/port-repo"
180make_repo "$port_repo"
181printf 'port-hog\t39999\t\t\n' >"$port_repo/.boxes"
182if 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
190else
191 grep -q 'no free host port in 39999-39999' <<<"$err"
192fi
193
194echo "cli-surface checks passed"
tests/fixtures/smolvm+0−105
@@ -1,105 +0,0 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4state="${SMOLVM_STATE_DIR:?SMOLVM_STATE_DIR must be set}"
5sentinel="${SMOLVM_CALLED:?SMOLVM_CALLED must point to a sentinel}"
6behavior="${SMOLVM_BEHAVIOR:-fail-exec}"
7mkdir -p "$state"
8printf '%q ' "$@" >>"$sentinel"
9printf '\n' >>"$sentinel"
10
11name_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
21case "${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 ;;
105esac
tests/lib.sh+3−3
@@ -1,7 +1,7 @@
1#!/usr/bin/env bash1#!/usr/bin/env bash
2# Shared test harness: a fresh $tmp per file, cleaned up on exit or signal2# 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),3# (129/130/143), plus make_repo() and the wait_for_* polling helpers used
4# plus make_repo() and the wait_for_* polling helpers used across the suite.4# across the suite.
5# Source this after `set -euo pipefail`; do not execute it directly.5# Source this after `set -euo pipefail`; do not execute it directly.
6 6
7PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"7PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -57,6 +57,6 @@ wait_for_pattern() { · 49 unmodified lines
57make_repo() {57make_repo() {
58 local dest="$1"58 local dest="$1"
59 mkdir -p "$dest/backups"59 mkdir -p "$dest/backups"
60 cp "$PROJECT_ROOT/box" "$PROJECT_ROOT/box.env" "$dest/"60 cp "$PROJECT_ROOT/box.env" "$dest/"
61 cp -R "$PROJECT_ROOT/guest" "$PROJECT_ROOT/provision" "$dest/"61 cp -R "$PROJECT_ROOT/guest" "$PROJECT_ROOT/provision" "$dest/"
62}62}
tests/lifecycle-smoke.sh+0−258
@@ -1,258 +0,0 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4# shellcheck source=tests/lib.sh
5source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
6
7mkdir -p "$tmp/valid/home/agent/workspace" "$tmp/invalid/not-home" "$tmp/gnupg" "$tmp/host-tmp"
8chmod 0700 "$tmp/gnupg"
9printf 'portable state\n' >"$tmp/valid/home/agent/workspace/probe.txt"
10ln -s probe.txt "$tmp/valid/home/agent/workspace/probe-link"
11ln "$tmp/valid/home/agent/workspace/probe.txt" "$tmp/valid/home/agent/workspace/probe-hard"
12printf 'invalid state\n' >"$tmp/invalid/not-home/probe.txt"
13tar czf "$tmp/valid.tgz" -C "$tmp/valid" .
14tar czf "$tmp/invalid.tgz" -C "$tmp/invalid" .
15python3 - "$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'
21import io
22import sys
23import tarfile
24
25def directory(name):
26 info = tarfile.TarInfo(name)
27 info.type = tarfile.DIRTYPE
28 info.mode = 0o700
29 return info
30
31with 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
39with 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
47with 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
55with 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
62with 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
69with 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
81with 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
98with 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
106with 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
114with 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
127with 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
140with 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
148with 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)
155PY
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
179if (
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
186fi
187
188export GNUPGHOME="$tmp/gnupg"
189export BOX_PASSPHRASE='lifecycle-smoke-passphrase'
190gpg --batch --yes --pinentry-mode loopback --passphrase "$BOX_PASSPHRASE" \
191 --symmetric --cipher-algo AES256 -o "$tmp/valid.tar.gz.gpg" "$tmp/valid.tgz"
192gpg --batch --yes --pinentry-mode loopback --passphrase "$BOX_PASSPHRASE" \
193 --symmetric --cipher-algo AES256 -o "$tmp/invalid.tar.gz.gpg" "$tmp/invalid.tgz"
194for 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"
199done
200
201TMPDIR="$tmp/host-tmp" "$PROJECT_ROOT/box" extract "$tmp/valid.tar.gz.gpg" "$tmp/extracted" >/dev/null
202cmp "$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 ]]
205cmp "$tmp/extracted/home/agent/workspace/probe-link" "$tmp/extracted/home/agent/workspace/probe.txt"
206python3 - "$tmp/extracted/home/agent/workspace/probe.txt" \
207 "$tmp/extracted/home/agent/workspace/probe-hard" <<'PY'
208import os
209import sys
210assert os.stat(sys.argv[1]).st_ino == os.stat(sys.argv[2]).st_ino
211PY
212[[ -z "$(find "$tmp/host-tmp" -mindepth 1 -maxdepth 1 -print -quit)" ]]
213
214for 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; }
225done
226
227sentinel="$tmp/smolvm-called"
228if 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
233fi
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
237make_repo "$tmp/repo"
238if 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
242fi
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
250if 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
254fi
255[[ ! -e "$tmp/wrong-pass" ]]
256[[ -z "$(find "$tmp/host-tmp" -mindepth 1 -maxdepth 1 -print -quit)" ]]
257
258echo "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
3set -euo pipefail
4
5# shellcheck source=tests/lib.sh
6source "$(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.
10hermes_fixture="$tmp/installer-hermes-home"
11mkdir -p "$hermes_fixture/sessions/deep" "$hermes_fixture/cron" "$hermes_fixture/logs"
12printf 'session\n' >"$hermes_fixture/sessions/deep/history.json"
13printf 'job\n' >"$hermes_fixture/cron/jobs.json"
14chmod 0500 "$hermes_fixture/sessions/deep"
15chmod 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.
33permission_stage="$tmp/restore-permission-model"
34mkdir -p "$permission_stage/home/agent"
35chmod 0711 "$permission_stage" "$permission_stage/home"
36chmod 0700 "$permission_stage/home/agent"
37python3 - "$permission_stage" <<'PY'
38import os
39import stat
40import sys
41from pathlib import Path
42
43stage = Path(sys.argv[1])
44home = stage / "home"
45agent = home / "agent"
46for 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
51assert stat.S_IMODE(agent.stat().st_mode) == 0o700
52assert os.access(agent, os.W_OK)
53PY
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.
66bundle_repo="$tmp/bundle-source"
67mkdir -p "$bundle_repo" "$tmp/bundle-nonrepo" "$tmp/bundle-verify-tmp"
68git -C "$bundle_repo" init -q
69printf 'base\n' >"$bundle_repo/state.txt"
70git -C "$bundle_repo" add state.txt
71git -C "$bundle_repo" -c user.name=Fixture -c user.email=fixture@example.invalid commit -q -m base
72base_commit="$(git -C "$bundle_repo" rev-parse HEAD)"
73git -C "$bundle_repo" bundle create "$tmp/complete.bundle" --all
74printf 'next\n' >>"$bundle_repo/state.txt"
75git -C "$bundle_repo" add state.txt
76git -C "$bundle_repo" -c user.name=Fixture -c user.email=fixture@example.invalid commit -q -m next
77git -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
90echo "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
3set -euo pipefail
4
5# shellcheck source=tests/lib.sh
6source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
7
8start_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
357echo "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
3set -euo pipefail
4
5# shellcheck source=tests/lib.sh
6source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
7
8mkdir -p "$tmp/valid/home/agent/workspace" "$tmp/gnupg" "$tmp/host-tmp"
9chmod 0700 "$tmp/gnupg"
10printf 'portable state\n' >"$tmp/valid/home/agent/workspace/probe.txt"
11tar czf "$tmp/valid.tgz" -C "$tmp/valid" .
12export GNUPGHOME="$tmp/gnupg"
13export BOX_PASSPHRASE='regression-passphrase'
14gpg --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.
33preexisting_repo="$tmp/preexisting-repo"
34make_repo "$preexisting_repo"
35mkdir -p "$tmp/preexisting-state/owned-elsewhere"
36if 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
41fi
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.
46race_repo="$tmp/race-repo"
47make_repo "$race_repo"
48if 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
53fi
54[[ -d "$tmp/race-state/race-winner" ]]
55if grep -q 'machine delete' "$tmp/race.calls"; then
56 echo "ambiguous create race winner was deleted" >&2
57 exit 1
58fi
59
60# TERM during provisioning rolls back the invocation-owned VM and registry.
61signal_repo="$tmp/signal-repo"
62make_repo "$signal_repo"
63if 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
68fi
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.
74create_signal_repo="$tmp/create-signal-repo"
75make_repo "$create_signal_repo"
76if 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
81fi
82[[ ! -d "$tmp/create-signal-state/create-signal-probe" ]]
83[[ ! -s "$create_signal_repo/.boxes" ]]
84grep -q 'machine create' "$tmp/create-signal.calls"
85grep -q -- '--cpus 4 --mem 8192 --overlay 64' "$tmp/create-signal.calls"
86grep -q 'machine delete' "$tmp/create-signal.calls"
87if grep -q 'machine start' "$tmp/create-signal.calls"; then
88 echo "create-time signal was not honored before start" >&2
89 exit 1
90fi
91
92# Optional Hermes API exposure constructs a distinct loopback forward and
93# passes its guest bridge port to the persistent workload.
94api_repo="$tmp/api-repo"
95make_repo "$api_repo"
96printf '\nEXPOSE_HERMES_API=1\nHERMES_API_PORT_LO=8650\nHERMES_API_PORT_HI=8650\n' >>"$api_repo/box.env"
97if 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
102fi
103grep -q -- '-p 8650:8650' "$tmp/api.calls"
104grep -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.
108python3 - "$tmp/hermes-import.zip" <<'PY'
109import sys, zipfile
110with 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")
115PY
116import_repo="$tmp/import-repo"
117make_repo "$import_repo"
118mkdir -p "$tmp/import-state/import-probe"
119printf 'import-probe\t\n' >"$import_repo/.boxes"
120if 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
125fi
126[[ ! -e "$tmp/import-no-consent.calls" ]]
127
128if 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
134fi
135[[ -e "$tmp/import-state/import-probe/gateway-disabled" ]]
136[[ ! -e "$tmp/import-state/import-probe/gateway-restarted" ]]
137grep -q 'hb gateway-disable' "$tmp/import-fail.calls"
138grep -q 'hb resume' "$tmp/import-fail.calls"
139grep -q '/data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls"
140if grep -q '/root/hermes-import-' "$tmp/import-fail.calls"; then
141 echo "Hermes import still stages beneath root-only storage" >&2
142 exit 1
143fi
144import_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)"
145import_cp_line="$(grep -n 'machine cp.*\.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)"
146import_chown_line="$(grep -n 'chown agent:agent /data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)"
147import_chmod_line="$(grep -n 'chmod 0600 /data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)"
148import_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" ]]
151import_remove_line="$(grep -n 'rm -f -- /data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)"
152import_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
155rm -f "$tmp/import-state/import-probe/paused"
156if 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
162fi
163[[ -e "$tmp/import-state/import-probe/gateway-disabled" ]]
164[[ ! -e "$tmp/import-state/import-probe/gateway-restarted" ]]
165grep -q 'hb resume' "$tmp/import-signal.calls"
166signal_remove_line="$(grep -n 'rm -f -- /data/home/agent/.config/hermes-box/import-' "$tmp/import-signal.calls" | head -1 | cut -d: -f1)"
167signal_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.
172for 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" ]]
196done
197grep -q 'Native import will intentionally skip 1 non-portable or transient file(s)' \
198 "$tmp/stopped-import-success.output"
199grep -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.
203pause_repo="$tmp/pause-repo"
204make_repo "$pause_repo"
205mkdir -p "$tmp/pause-state/pause-probe"
206printf 'pause-probe\t\n' >"$pause_repo/.boxes"
207if 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
212fi
213grep -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.
218contention_repo="$tmp/contention-repo"
219make_repo "$contention_repo"
220if 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
227fi
228[[ ! -d "$tmp/contention-state/contention-probe" ]]
229grep -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.
233stale_lock_repo="$tmp/stale-lock-repo"
234make_repo "$stale_lock_repo"
235if 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
241fi
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.
248delete_repo="$tmp/delete-repo"
249make_repo "$delete_repo"
250if 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
255fi
256[[ -d "$tmp/delete-state/delete-probe" ]]
257grep -q '^delete-probe' "$delete_repo/.boxes"
258
259# A delete error plus a generic status error is never interpreted as absence.
260delete_status_repo="$tmp/delete-status-repo"
261make_repo "$delete_status_repo"
262if 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
267fi
268[[ -d "$tmp/delete-status-state/delete-status-probe" ]]
269grep -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.
273start_delete_repo="$tmp/start-delete-repo"
274make_repo "$start_delete_repo"
275if 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
280fi
281[[ -d "$tmp/start-delete-state/start-delete-probe" ]]
282grep -q '^start-delete-probe' "$start_delete_repo/.boxes"
283
284# Save failures remove the guest plaintext before resuming Executor.
285save_repo="$tmp/save-repo"
286make_repo "$save_repo"
287mkdir -p "$tmp/save-state/save-probe"
288printf 'save-probe\t\n' >"$save_repo/.boxes"
289if 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
295fi
296grep -q '/root/hb-data-' "$tmp/save.calls"
297remove_line="$(grep -n 'rm -f -- /root/hb-data-' "$tmp/save.calls" | head -1 | cut -d: -f1)"
298resume_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.
304restore_repo="$tmp/restore-repo"
305make_repo "$restore_repo"
306if 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
311fi
312[[ ! -d "$tmp/restore-state/restore-probe" ]]
313if grep -q 'hb resume' "$tmp/restore.calls"; then
314 echo "failed restore resumed Executor before deleting its new VM" >&2
315 exit 1
316fi
317grep -q 'machine delete' "$tmp/restore.calls"
318grep -q 'chmod 0600 /root/hb-restore-' "$tmp/restore.calls"
319restore_remove_line="$(grep -n 'rm -f -- /root/hb-restore-' "$tmp/restore.calls" | head -1 | cut -d: -f1)"
320restore_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" ]]
322grep -q 'set -euo pipefail' "$tmp/restore.calls"
323grep -q 'trap.*rm -f --.*archive' "$tmp/restore.calls"
324
325
326echo "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
3set -euo pipefail
4
5# shellcheck source=tests/lib.sh
6source "$(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)
15for 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
25done
26
27# Scheduled host health gates on an already-running box and never starts one.
28host_root="$tmp/tx9-host-root"
29mkdir -p "$host_root" "$tmp/tx9-host-state/scheduled"
30printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >>"${TX9_BOX_CALLS:?}"\n' >"$host_root/box"
31chmod +x "$host_root/box"
32if 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
38fi
39[[ ! -e "$tmp/tx9-inactive.box" ]]
40if grep -q 'machine start' "$tmp/tx9-inactive.smolvm"; then
41 echo "scheduled health started an inactive box" >&2
42 exit 1
43fi
44PATH="$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
48grep -q '^doctor scheduled$' "$tmp/tx9-active.box"
49
50# Sourced configuration cannot redirect an operation to another box name.
51mkdir -p "$tmp/tx9-config/boxes"
52printf 'name=global-override\nTX9_BOX_CONFIG_DIR=%s\n' "$tmp/tx9-config/boxes" >"$tmp/tx9-config/global.conf"
53printf 'name=box-override\n' >"$tmp/tx9-config/boxes/scheduled.conf"
54PATH="$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.
62PATH="$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.
69PATH="$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 &
74supervisor_pid=$!
75pids+=("$supervisor_pid")
76wait_for_pattern '^start scheduled$' "$tmp/tx9-supervise-stop.box"
77kill -TERM "$supervisor_pid"
78wait "$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.
83if 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
90fi
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.
94printf '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.
115export TX9_CONFIG="/dev/null"
116export TX9_BOX_CONFIG_DIR="$tmp/prune-no-box-config"
117prune_dir="$tmp/prune-backups"
118mkdir -p "$prune_dir"
119for 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"
123done
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
127TX9_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.
133for 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" ]]
137done
138
139TX9_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
143missing_nas="$tmp/missing-nas"
144TX9_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
148if "$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
151fi
152if 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
155fi
156
157echo "tx9-host regression checks passed"
tests/static.sh+29−95
@@ -10,113 +10,47 @@ cd "$ROOT" · 9 unmodified lines
10regression_files=(tests/regressions-*.sh)10regression_files=(tests/regressions-*.sh)
11 11
12files=(12files=(
13 box
14 guest/hb13 guest/hb
15 guest/hb-workload14 guest/hb-workload
16 guest/lib-mcp.sh15 guest/lib-mcp.sh
17 guest/profile.sh16 guest/profile.sh
18 guest/agent-bash-profile.sh17 guest/agent-bash-profile.sh
19 provision/provision.sh18 provision/provision.sh
20 ops/tx9-host19 docker/entrypoint.sh
21 ops/tx9-backup-prune20 docker/executor-entrypoint.sh
22 tests/lib.sh21 tests/lib.sh
23 tests/lifecycle-smoke.sh
24 tests/hermes-state.sh22 tests/hermes-state.sh
25 tests/cli-surface.sh
26 "${regression_files[@]}"23 "${regression_files[@]}"
27 tests/fixtures/smolvm
28)24)
29 25
30bash -n "${files[@]}"26bash -n "${files[@]}"
31for 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; do27python3 -c 'compile(open("guest/hermes-state", encoding="utf-8").read(), "guest/hermes-state", "exec")'
28for file in guest/hb guest/hb-workload guest/hermes-state provision/provision.sh tests/hermes-state.sh "${regression_files[@]}"; do
32 [[ -x "$file" ]] || { echo "not executable: $file" >&2; exit 1; }29 [[ -x "$file" ]] || { echo "not executable: $file" >&2; exit 1; }
33done30done
34 31
32# --- guest/hb invariants ------------------------------------------------
35grep -q 'WIRE_EXECUTOR_MCP' guest/hb33grep -q 'WIRE_EXECUTOR_MCP' guest/hb
36grep -q 'http://127.0.0.1:.*}/mcp' guest/hb34# HTTP MCP wiring goes through _executor_url (http://$EXECUTOR_HOST:$PORT/mcp,
35# loopback by default, container-DNS name in the compose split).
36grep -q "printf 'http://%s:%s/mcp' \"\$EXECUTOR_HOST\"" guest/hb
37grep -q "printf 'http://%s:%s/mcp'.*EXECUTOR_PORT:-4788" guest/hb
37if grep -q 'args = \["mcp"\]' guest/hb; then38if grep -q 'args = \["mcp"\]' guest/hb; then
38 echo "legacy stdio Executor MCP wiring remains" >&239 echo "legacy stdio Executor MCP wiring remains" >&2
39 exit 140 exit 1
40fi41fi
41grep -q 'trap _cleanup EXIT' box
42grep -q 'Decrypting and validating archive before creating a VM' box
43grep -q 'refusing to overwrite existing backup' box
44grep -q 'guest_tmp="/root/hb-data-' box
45grep -q 'guest_tmp="/root/hb-restore-' box
46if grep -Eq 'guest_tmp="/tmp/hb-(data|restore)-' box; then
47 echo "smolvm-incompatible /tmp archive staging remains" >&2
48 exit 1
49fi
50if grep -q '\blsof\b' box; then
51 echo "lsof dependency remains" >&2
52 exit 1
53fi
54grep -q 'protocolVersion.*2025-03-26' guest/lib-mcp.sh
55grep -q 'source .*guest/lib-mcp\.sh' box
56grep -q 'lib-mcp\.sh' guest/hb
57grep -q 'ca-certificates curl git jq tmux' provision/provision.sh
58grep -q 'runuser -u agent.*hb.*write-manifest' provision/provision.sh
59grep -q -- '--commit.*sha' provision/provision.sh
60grep -q 'HERMES_INSTALLER_SHA256="[0-9a-f]\{64\}"' box.env
61grep -q 'sha256sum --check --status' provision/provision.sh
62python3 - <<'PY'
63from pathlib import Path
64
65source = Path("provision/provision.sh").read_text()
66checksum = source.index('sha256sum --check --status')
67destructive_checkout = source.index('rm -rf "$install_dir"')
68if checksum >= destructive_checkout:
69 raise SystemExit("Hermes checkout is modified before installer verification")
70installer = source.index('if bash "$installer"')
71user_args = source.index('"${args[@]}"', installer)
72pinned_dir = source.index('--dir "$install_dir"', user_args)
73head_check = source.index('git -C "$install_dir" rev-parse HEAD', installer)
74origin_reset = source.index('git -C "$install_dir" remote set-url origin', head_check)
75if not installer < user_args < pinned_dir < head_check < origin_reset:
76 raise SystemExit("bundle origin is reset before installer and exact-HEAD verification")
77
78box = Path("box").read_text()
79stage_root = box.index('chown root:root "$stage" "$stage/home"')
80stage_traverse = box.index('chmod 0711 "$stage" "$stage/home"', stage_root)
81nested_owner = box.index('chown -R agent:agent "$stage/home/agent"', stage_traverse)
82staged_verify = box.index('setpriv --reuid=agent', nested_owner)
83if not stage_root < stage_traverse < nested_owner < staged_verify:
84 raise SystemExit("restore ownership boundary is not established before agent verification")
85if 'chown agent:agent "$stage"' in box:
86 raise SystemExit("restore stage root is writable by the agent")
87PY
88if grep -q 'curl .*install.sh.*|.*bash' provision/provision.sh; then
89 echo "unverified Hermes installer execution remains" >&2
90 exit 1
91fi
92grep -q 'HERMES_INSTALL_ARGS cannot override protected installer option' provision/provision.sh
93if 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
96fi
97grep -q 'BOX_OVERLAY_GIB="64"' box.env
98grep -q 'gateway-disable' box
99grep -Fq -- '--exclude="*/.hermes/*.db-wal"' box
100if grep -Fq -- '--exclude="*.db-wal"' box; then
101 echo "unrelated /data SQLite WAL files are still excluded" >&2
102 exit 1
103fi
104grep -q 'LoadCredential=backup-passphrase' ops/systemd/tx9-backup@.service
105if grep -q '^Requires=tx9-box@' ops/systemd/tx9-*.service; then
106 echo "timer service still pulls the box service active" >&2
107 exit 1
108fi
109grep -q '^Type=simple$' ops/systemd/tx9-box@.service
110grep -q 'tx9-host supervise %i' ops/systemd/tx9-box@.service
111grep -Fq 'ExecStop=-/bin/kill -TERM $MAINPID' ops/systemd/tx9-box@.service
112if grep -q '^RemainAfterExit=' ops/systemd/tx9-box@.service; then
113 echo "box supervisor is still modeled as a oneshot service" >&2
114 exit 1
115fi
116if grep -q 'chown -R' guest/hb; then42if grep -q 'chown -R' guest/hb; then
117 echo "reconcile still recursively changes durable-tree ownership" >&243 echo "reconcile still recursively changes durable-tree ownership" >&2
118 exit 144 exit 1
119fi45fi
46grep -q 'Executor target port closed while quiesced' guest/hb
47grep -q 'gateway-is-disabled' guest/hb
48grep -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.
51grep -q 'BOXD_EXECUTOR_TOKEN' guest/hb
52
53# --- guest/hb-workload invariants ----------------------------------------
120grep -q 'EXECUTOR_TARGET_PORT="${EXECUTOR_PORT:-4788}"' guest/hb-workload54grep -q 'EXECUTOR_TARGET_PORT="${EXECUTOR_PORT:-4788}"' guest/hb-workload
121grep -q 'TCP:127.0.0.1:$EXECUTOR_TARGET_PORT' guest/hb-workload55grep -q 'TCP:127.0.0.1:$EXECUTOR_TARGET_PORT' guest/hb-workload
122grep -q '_wait_api_ready' guest/hb-workload56grep -q '_wait_api_ready' guest/hb-workload
@@ -125,18 +59,18 @@ grep -q '\[ "$reconciled" = 1 \]' guest/hb-workload · 2 unmodified lines
125grep -q '_refresh_targets' guest/hb-workload59grep -q '_refresh_targets' guest/hb-workload
126grep -q '\. "$BOX_ENV"' guest/hb-workload60grep -q '\. "$BOX_ENV"' guest/hb-workload
127grep -q '_stop_executor_bridge' guest/hb-workload61grep -q '_stop_executor_bridge' guest/hb-workload
128grep -q 'LEGACY_QUIESCE_FILE' guest/hb guest/hb-workload62
129grep -q 'Executor target port closed while quiesced' guest/hb63# --- provisioning / config invariants ------------------------------------
130grep -q 'gateway-is-disabled' guest/hb box64grep -q 'HERMES_INSTALLER_SHA256="[0-9a-f]\{64\}"' box.env
131grep -q 'EXECUTOR_PORT:-4788.*mcp' guest/hb65grep -q 'tools) tools_only ;;' provision/provision.sh
132grep -q 'guest_tmp="/data/home/agent/.config/hermes-box/import-' box66grep -q 'assets) assets_only ;;' provision/provision.sh
133if grep -q 'guest_tmp="/root/hermes-import-' box; then67
134 echo "Hermes import remains unreadable to the agent" >&268# --- docker build assets --------------------------------------------------
135 exit 169grep -q 'provision.sh tools' docker/Dockerfile
136fi70grep -q 'provision.sh assets' docker/Dockerfile
71grep -q 'EXECUTOR_MCP_TOKEN' docker/executor-entrypoint.sh
72grep -q -- '--hostname 0.0.0.0' docker/executor-entrypoint.sh
73
137grep -qi 'networking is always enabled' README.md74grep -qi 'networking is always enabled' README.md
138grep -q 'check.yml. runs it on every push' README.md
139[[ -f .depot/workflows/check.yml ]]
140grep -q 'make check' .depot/workflows/check.yml
141 75
142echo "static checks passed"76echo "static checks passed"