tx9 Go CLI: full v1 command surface on the Docker agent/executor runtime
Unified · Split
| @@ -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@v4 | 17 | - 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 jq | 24 | - name: Install jq |
| 19 | run: | | 25 | run: | |
| 20 | sudo apt-get update | 26 | sudo apt-get update |
| @@ -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/ | ||
| @@ -0,0 +1,64 @@ | |||
| 1 | name: release | ||
| 2 | |||
| 3 | # Tag-push CI for tx9's distribution (docs/tx9-cli-design.md decision 10): | ||
| 4 | # push a `v*` tag, get cross-compiled binaries + checksums.txt attached to | ||
| 5 | # a GitHub release that `internal/selfupdate` (via `tx9 upgrade`) and | ||
| 6 | # scripts/install.sh both know how to consume. | ||
| 7 | |||
| 8 | on: | ||
| 9 | push: | ||
| 10 | tags: | ||
| 11 | - "v*" | ||
| 12 | |||
| 13 | permissions: | ||
| 14 | contents: write | ||
| 15 | |||
| 16 | jobs: | ||
| 17 | release: | ||
| 18 | runs-on: ubuntu-latest | ||
| 19 | steps: | ||
| 20 | - uses: actions/checkout@v4 | ||
| 21 | |||
| 22 | - name: Set up Go | ||
| 23 | uses: actions/setup-go@v5 | ||
| 24 | with: | ||
| 25 | go-version: "1.26" | ||
| 26 | |||
| 27 | - name: Install jq | ||
| 28 | run: | | ||
| 29 | sudo apt-get update | ||
| 30 | sudo apt-get install -y --no-install-recommends jq | ||
| 31 | |||
| 32 | - name: Install shellcheck | ||
| 33 | # Pinned to match .depot/workflows/check.yml and local dev, so a | ||
| 34 | # release build never fails a check that `make check` passed | ||
| 35 | # locally for a different reason (older/newer shellcheck). | ||
| 36 | env: | ||
| 37 | SHELLCHECK_VERSION: v0.11.0 | ||
| 38 | run: | | ||
| 39 | curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" \ | ||
| 40 | | tar -xJ | ||
| 41 | sudo install "shellcheck-${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck | ||
| 42 | shellcheck --version | ||
| 43 | |||
| 44 | # `make check` (syntax + shellcheck + static contracts + guest-script | ||
| 45 | # regressions + go-check) needs no Docker daemon and no network | ||
| 46 | # access: none of tests/*.sh shell out to `docker` (checked — they | ||
| 47 | # only grep the repo's Docker-related source files/strings), so | ||
| 48 | # nothing here is trimmed down; the full local gate runs unmodified | ||
| 49 | # before a release is ever built. | ||
| 50 | - name: make check | ||
| 51 | run: make check | ||
| 52 | |||
| 53 | - name: Derive VERSION from tag | ||
| 54 | id: version | ||
| 55 | run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" | ||
| 56 | |||
| 57 | - name: Build dist/ (cross-compiled binaries + checksums.txt) | ||
| 58 | run: make dist VERSION=${{ steps.version.outputs.version }} | ||
| 59 | |||
| 60 | - name: Create GitHub release | ||
| 61 | uses: softprops/action-gh-release@v2 | ||
| 62 | with: | ||
| 63 | files: dist/* | ||
| 64 | generate_release_notes: true | ||
| @@ -14,3 +14,10 @@ · 13 unmodified lines | |||
| 14 | !/artifacts/.gitkeep | 14 | !/artifacts/.gitkeep |
| 15 | 15 | ||
| 16 | __pycache__/ | 16 | __pycache__/ |
| 17 | /.boxd-locks/ | ||
| 18 | /.boxd/ | ||
| 19 | |||
| 20 | # Go build output. | ||
| 21 | /bin/ | ||
| 22 | /tx9 | ||
| 23 | /dist/ | ||
| @@ -1,27 +1,30 @@ | |||
| 1 | .PHONY: check syntax lint test check-hermes-pin | 1 | .PHONY: check syntax lint test check-hermes-pin tx9 go-check dist |
| 2 | 2 | ||
| 3 | # tests/regressions-*.sh is globbed so new split-out regression files don't | 3 | # tests/regressions-*.sh is globbed so new split-out regression files don't |
| 4 | # require touching this Makefile. | 4 | # require touching this Makefile. |
| 5 | SHELL_FILES := box guest/hb guest/hb-workload guest/lib-mcp.sh guest/profile.sh guest/agent-bash-profile.sh provision/provision.sh ops/tx9-host ops/tx9-backup-prune tests/lib.sh tests/static.sh tests/lifecycle-smoke.sh tests/hermes-state.sh tests/cli-surface.sh $(wildcard tests/regressions-*.sh) tests/fixtures/smolvm | 5 | SHELL_FILES := guest/hb guest/hb-workload guest/lib-mcp.sh guest/profile.sh guest/agent-bash-profile.sh provision/provision.sh docker/entrypoint.sh docker/executor-entrypoint.sh tests/lib.sh tests/static.sh tests/hermes-state.sh $(wildcard tests/regressions-*.sh) |
| 6 | 6 | ||
| 7 | syntax: | 7 | syntax: |
| 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 | ||
| 11 | lint: | 12 | lint: |
| 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 | ||
| 15 | test: | 20 | test: |
| 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.sh | 23 | ./tests/static.sh |
| 19 | ./tests/lifecycle-smoke.sh | ||
| 20 | ./tests/hermes-state.sh | 24 | ./tests/hermes-state.sh |
| 21 | ./tests/cli-surface.sh | ||
| 22 | for f in tests/regressions-*.sh; do ./"$$f" || exit 1; done | 25 | for f in tests/regressions-*.sh; do ./"$$f" || exit 1; done |
| 23 | 26 | ||
| 24 | check: syntax lint test | 27 | check: 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 a | 30 | # 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 | fi | 50 | fi |
| 51 | |||
| 52 | VERSION ?= dev | ||
| 53 | |||
| 54 | # -buildvcs=false: this worktree's .git is a linked-worktree gitlink file, not | ||
| 55 | # a directory, and `go build`'s VCS-root walk doesn't recognize it — it walks | ||
| 56 | # past the real repo and can land on an unrelated .git elsewhere up the tree, | ||
| 57 | # failing with "error obtaining VCS status: exit status 128". The CLI's | ||
| 58 | # version comes from -ldflags, not VCS stamping, so this is a no-op for us. | ||
| 59 | tx9: | ||
| 60 | go build -buildvcs=false -ldflags "-X github.com/davis7dotsh/tx9/internal/version.Version=$(VERSION)" -o bin/tx9 . | ||
| 61 | |||
| 62 | go-check: | ||
| 63 | @test -z "$$(gofmt -l .)" || { echo "gofmt: unformatted files:" >&2; gofmt -l . >&2; exit 1; } | ||
| 64 | go vet -buildvcs=false ./... | ||
| 65 | go build -buildvcs=false ./... | ||
| 66 | go test -buildvcs=false ./... | ||
| 67 | |||
| 68 | # Cross-compiles the release binaries + checksums.txt into dist/. Asset | ||
| 69 | # naming here MUST match internal/selfupdate.AssetName and | ||
| 70 | # .github/workflows/release.yml: plain, uncompressed executables named | ||
| 71 | # tx9_<GOOS>_<GOARCH>, plus a checksums.txt in `sha256sum` output format. | ||
| 72 | DIST_PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 | ||
| 73 | |||
| 74 | dist: | ||
| 75 | rm -rf dist | ||
| 76 | mkdir -p dist | ||
| 77 | for platform in $(DIST_PLATFORMS); do \ | ||
| 78 | goos=$${platform%/*}; goarch=$${platform#*/}; \ | ||
| 79 | out="dist/tx9_$${goos}_$${goarch}"; \ | ||
| 80 | echo "building $$out"; \ | ||
| 81 | GOOS=$$goos GOARCH=$$goarch go build -buildvcs=false -ldflags "-X github.com/davis7dotsh/tx9/internal/version.Version=$(VERSION)" -o "$$out" . || exit 1; \ | ||
| 82 | done | ||
| 83 | cd dist && { command -v sha256sum >/dev/null 2>&1 && sha256sum tx9_* > checksums.txt || shasum -a 256 tx9_* > checksums.txt; } | ||
| 84 | @echo "dist/ ready (VERSION=$(VERSION))" | ||
| @@ -1,247 +1,97 @@ | |||
| 1 | # hermes-box-lite | 1 | # tx9 |
| 2 | 2 | ||
| 3 | Small, isolated agent VMs with **Claude Code, Codex, Hermes, and Executor**. | 3 | Isolated, portable agent boxes with **Claude Code, Codex, Hermes, and |
| 4 | Each box runs on [smolvm](https://smolmachines.com), has no host filesystem | 4 | Executor**, running on Docker. Each box is a pair of containers on a private |
| 5 | mounts, and keeps portable user state under `/data`. | 5 | network — a free-rein agent container and an isolated Executor container — |
| 6 | 6 | with all durable state on a named volume that travels as an encrypted, | |
| 7 | [Usage guide and quick start](docs/usage-guide.html) | 7 | validated backup archive. |
| 8 | 8 | ||
| 9 | This is deliberately simpler than full Hermes Box: there are no release locks, | 9 | **Status: experiment, mid-transition.** The bash prototypes (smolvm `./box` |
| 10 | offline artifact closures, or transactional component updates. The reliability | 10 | and the compose `./docker/boxd`) have been retired in favor of a Go CLI |
| 11 | boundary is narrower: safe lifecycle operations and verified state backups. | 11 | (`tx9`, this repo, embedding its provisioning and guest assets) that is now |
| 12 | implemented and under active development — still experimental, not yet | ||
| 13 | released. See [docs/tx9-cli-design.md](docs/tx9-cli-design.md) for the full | ||
| 14 | design and [docs/docker-architecture.md](docs/docker-architecture.md) for | ||
| 15 | the runtime architecture and its verification history. | ||
| 12 | 16 | ||
| 13 | ## Model | 17 | ## Model |
| 14 | 18 | ||
| 15 | ```text | 19 | ```text |
| 16 | ./box new alpha | 20 | tx9 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/local | 23 | │ ┌───────────────────────┐ ┌─────────────────────────┐ │ |
| 21 | │ portable state: /data/home/agent | 24 | │ │ agent ("linux land") │ │ executor │ │ |
| 22 | │ .claude · .codex · .hermes · .executor · workspace · XDG state | 25 | │ │ claude·codex·hermes │ │ Executor daemon only │ │ |
| 23 | │ | 26 | │ │ gateway, sudo, free │ │ dashboard / + MCP /mcp │ │ |
| 24 | │ ./box save alpha | 27 | │ │ agent volume = the box│ │ scratch volume │ │ |
| 28 | │ │ no published ports │ │ one 0.0.0.0 host port │ │ | ||
| 29 | │ └───────────┬───────────┘ └───┬─────────────────────┘ │ | ||
| 30 | │ │ http://executor:4788 │ bearer-token gated │ | ||
| 31 | │ └── private network ───┘ │ | ||
| 32 | └──────────────────────────────────────────────────────────────┘ | ||
| 25 | ▼ | 33 | ▼ |
| 26 | backups/alpha-<timestamp>.tar.gz.gpg | 34 | <box>-<timestamp>.tx9 encrypted, validated state archive |
| 27 | encrypted, validated state archive plus runtime-version manifest | ||
| 28 | ``` | 35 | ``` |
| 29 | 36 | ||
| 30 | The VM protects the host filesystem. Networking is always enabled because | 37 | The agent volume is the box; containers are disposable; tools are image |
| 31 | provisioning and the agent tools require internet access. Tools inside one box | 38 | layers ("upgrade" = rebuild image + recreate containers). Executor state is |
| 32 | share the `agent` account, credentials, network, and passwordless guest sudo; | 39 | scratch and does not travel. Networking is always enabled because the agent |
| 33 | they are one trust domain. | 40 | tools require internet access. |
| 34 | |||
| 35 | `/data` is an organizational boundary on the VM's persistent root overlay, not | ||
| 36 | a separate virtual disk. Deleting or losing the VM loses live state, so keep | ||
| 37 | current backups for anything important. | ||
| 38 | |||
| 39 | ## Quick start | ||
| 40 | |||
| 41 | Host prerequisites: | ||
| 42 | 41 | ||
| 43 | - macOS with the smolvm-supported virtualization stack, or Linux on x86_64 or | 42 | ## 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 | ||
| 51 | On Linux, verify KVM access before creating a box: | 44 | | Path | Role | |
| 52 | 45 | |---|---| | |
| 53 | ```bash | 46 | | `box.env` | Build/runtime configuration: tool pins, Hermes installer SHA pin, feature flags | |
| 54 | test -r /dev/kvm -a -w /dev/kvm | 47 | | `provision/provision.sh` | Runs inside `docker build` (`tools` + `assets` layers); installs everything | |
| 55 | smolvm machine ls | 48 | | `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 | ```bash | 51 | | `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 | |||
| 75 | Set `BOX_PASSPHRASE` for unattended encryption/decryption. Otherwise GPG | ||
| 76 | passphrases are read from the terminal without echo. | ||
| 77 | 52 | ||
| 78 | ## Commands | 53 | ## In-box control (`hb`) |
| 79 | 54 | ||
| 80 | | Command | Behavior | | 55 | Inside 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 | |||
| 95 | Mutating commands use local lock directories so concurrent operations cannot | ||
| 96 | modify the same box or registry entry. | ||
| 97 | |||
| 98 | ## Executor and MCP | ||
| 99 | |||
| 100 | Executor runs continuously on guest `127.0.0.1:4788`. If web exposure is | ||
| 101 | enabled, smolvm forwards a unique host-loopback port in the configured | ||
| 102 | `4790–4999` range. `box open` reads Executor's stable local token and hands the | ||
| 103 | authenticated URL directly to the browser without printing the credential. | ||
| 104 | |||
| 105 | `box open` launches a browser on the machine running `./box` itself (via | ||
| 106 | `BOX_BROWSER`, `xdg-open`, or `gio open`). On a headless host reached over SSH | ||
| 107 | or Tailscale rather than at its console, that handoff can report success | ||
| 108 | without any browser becoming visible to you — these openers consider the job | ||
| 109 | done once they hand the URL to a desktop session or portal, not once a human | ||
| 110 | sees a window. To reach the UI from a separate client machine instead, forward | ||
| 111 | the host-loopback port yourself, e.g. `ssh -L <port>:127.0.0.1:<port> <host>`, | ||
| 112 | read the live token from inside the box with `./box enter` then `hb | ||
| 113 | executor-token` (so it never touches host logs, shell history, or `box`'s own | ||
| 114 | output), and open `http://localhost:<port>/?_token=<token>` in a browser on | ||
| 115 | that client machine. | ||
| 116 | |||
| 117 | The workload keeps one Executor daemon alive. Claude and Codex connect to that | ||
| 118 | daemon's authenticated Streamable HTTP endpoint; they do not launch `executor | ||
| 119 | mcp` as a second process. The bearer token remains under `/data` in Executor's | ||
| 120 | mode-0600 auth file and a mode-0600 Codex environment file. Set | ||
| 121 | `WIRE_EXECUTOR_MCP=0` before provisioning to disable registration. | ||
| 122 | |||
| 123 | The guest doctor check—and the host check when web exposure is enabled—performs | ||
| 124 | a complete MCP handshake: it uses `jq` to validate the JSON `initialize` | ||
| 125 | result and negotiated protocol version, sends the | ||
| 126 | initialized notification, and closes the session best-effort. Requests have | ||
| 127 | bounded timeouts. A listening socket, malformed result, 404 page, or HTTP 500 | ||
| 128 | response does not count as healthy. Without an exposed port, doctor explicitly | ||
| 129 | reports that the host endpoint check was skipped. | ||
| 130 | |||
| 131 | Hermes runs as a supervised foreground gateway alongside Executor. Fresh and | ||
| 132 | imported boxes begin with the gateway durably disabled, so setup and migration | ||
| 133 | cannot accidentally create a second Discord writer. Enable it explicitly only | ||
| 134 | after authentication or the final single-writer cutover. Inside a box: | ||
| 135 | 56 | ||
| 136 | ```bash | 57 | ```bash |
| 137 | hb status | 58 | hb status / hb doctor / hb versions |
| 138 | hb doctor | 59 | hb down # durable pause; the reconcile loop won't restart services |
| 139 | hb versions | ||
| 140 | hb down # durable pause; the workload will not immediately restart it | ||
| 141 | hb up | 60 | hb up |
| 142 | hb acknowledge-active-paths | ||
| 143 | hb cutover-ready | ||
| 144 | hb gateway-enable --confirm-single-writer I_CONFIRM_NO_OTHER_GATEWAY_USES_THIS_IDENTITY | 61 | hb gateway-enable --confirm-single-writer I_CONFIRM_NO_OTHER_GATEWAY_USES_THIS_IDENTITY |
| 145 | hb gateway-disable | 62 | hb gateway-disable |
| 146 | hb verify-state | 63 | hb verify-state |
| 147 | hb wire-mcp # replace stale/legacy wiring with authenticated HTTP MCP | 64 | hb wire-mcp # (re)register Executor's authenticated HTTP MCP for claude+codex |
| 148 | hb logs executor | 65 | hb logs executor |
| 149 | ``` | 66 | ``` |
| 150 | 67 | ||
| 151 | ## Backup and restore guarantees | 68 | Fresh and restored boxes begin with the Hermes gateway durably disabled so |
| 152 | 69 | setup and migration cannot create a second Discord writer; enabling it is | |
| 153 | `box save`: | 70 | always an explicit, confirmed step. Executor runs remotely from the agent's |
| 154 | 71 | perspective (`EXECUTOR_HOST=executor`): `hb` checks reachability and wires | |
| 155 | 1. acquires the box mutation lock; | 72 | MCP with the injected bearer token; it never spawns or stops the daemon. |
| 156 | 2. gracefully quiesces Hermes and Executor, waits for shutdown, checkpoints | 73 | |
| 157 | SQLite, and runs integrity checks; | 74 | ## Backup guarantees |
| 158 | 3. atomically writes a mode-0600 runtime-version manifest at | 75 | |
| 159 | `~/.config/hermes-box/runtime-manifest` and archives `/data` inside the guest; | 76 | Saves quiesce the gateway, checkpoint SQLite, archive agent `/data` (minus |
| 160 | 4. excludes caches, native dependency trees, checkpointed Hermes SQLite | 77 | caches, 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`; | 79 | symlink/hardlink targets, link cycles), encrypt with GPG AES-256, then |
| 163 | 5. validates its paths and required durable-home layout; | 80 | decrypt-verify before publishing without overwriting. Restores validate |
| 164 | 6. encrypts to a unique temporary destination; | 81 | before creating anything, stage-then-promote inside a throwaway container, |
| 165 | 7. decrypts and lists the encrypted result as a verification pass; and | 82 | re-arm the quiesce and gateway-disable markers, mint a fresh executor token, |
| 166 | 8. publishes it without overwriting an existing backup. | 83 | and rewire MCP automatically. |
| 167 | |||
| 168 | Host and guest plaintext temporaries and operation locks are removed by exit | ||
| 169 | and signal traps. Guest plaintext is removed before services are selectively | ||
| 170 | resumed to their prior state, even | ||
| 171 | when a later save step fails. Do not actively edit workspace files during a | ||
| 172 | save; Executor is quiesced, but arbitrary editor or agent processes are not | ||
| 173 | frozen. | ||
| 174 | |||
| 175 | `box load` decrypts and validates before spending time or creating resources. | ||
| 176 | It restores while Executor is paused, removes hidden as well as normal old | ||
| 177 | state into staging, verifies databases before the swap, refreshes | ||
| 178 | repository-owned guest assets, keeps the Hermes gateway disabled, starts a fresh | ||
| 179 | Executor daemon, repairs MCP wiring, and runs the doctor. A failed new or | ||
| 180 | load operation—including interruption by HUP, INT, or TERM—attempts to remove | ||
| 181 | only an incomplete destination VM proven to have been created by that | ||
| 182 | invocation. Registry metadata is removed only after VM absence is verified. If | ||
| 183 | cleanup cannot delete the VM or acquire the registry lock, it preserves | ||
| 184 | metadata and prints recovery guidance. An unmanaged or ambiguous same-named | ||
| 185 | smolvm machine is never deleted. | ||
| 186 | |||
| 187 | Backups are state-portable, not VM images. Hermes itself is pinned by full Git | ||
| 188 | SHA and rebuilt for the guest architecture. Ubuntu, Claude, Codex, and Executor | ||
| 189 | packages can still drift; the runtime manifest records their versions. | ||
| 190 | |||
| 191 | Native `hermes backup` imports add ZIP traversal/symlink validation, staging and | ||
| 192 | duplicate normalized-path rejection, atomic swap, SQLite integrity and inventory | ||
| 193 | baselines, and active-file-only path | ||
| 194 | rewrites. Historical sessions, memories, databases, and logs are not rewritten. | ||
| 195 | Validation also requires an importable canonical root Hermes marker after all | ||
| 196 | skip rules, so a transient-only archive cannot replace existing state with an | ||
| 197 | empty home. | ||
| 198 | The source ZIP is opened read-only and remains unchanged. Root-level | ||
| 199 | `~/.hermes/tmp` content and otherwise importable files with thin Mach-O magic or | ||
| 200 | a structurally valid universal Mach-O header are deterministically skipped | ||
| 201 | before staging, including beneath approved external provider trees. The fat | ||
| 202 | header validation distinguishes the shared `CAFEBABE` prefix from portable Java | ||
| 203 | class files. A successful import with exclusions emits a warning and records | ||
| 204 | exact skipped paths, byte counts, reasons, and normalized file/byte totals in | ||
| 205 | the import manifest; it does not silently treat macOS binaries as portable. | ||
| 206 | External provider state is previewed and rejected unless the operator explicitly | ||
| 207 | approves every safe top-level destination with repeatable `--external NAME` | ||
| 208 | flags; reserved destinations such as `.ssh`, `.config`, and shell startup files | ||
| 209 | are never imported. Ordinary supplemental trees remain separate. | ||
| 210 | See [Nexus operations and Eventide migration](docs/nexus-operations.md) for the | ||
| 211 | host systemd units, scheduled encrypted backups, NAS rules, and cutover gates. | ||
| 212 | |||
| 213 | ## Tool installation and state | ||
| 214 | |||
| 215 | | Tool | Installation | Durable state | | ||
| 216 | |---|---|---| | ||
| 217 | | Claude Code | current npm package under `/opt/hermes-box` | `~/.claude` | | ||
| 218 | | Codex | current npm package under `/opt/hermes-box` | `~/.codex` | | ||
| 219 | | Hermes | official installer at `HERMES_GIT_SHA`, optionally seeded by a local Git bundle | `~/.hermes` | | ||
| 220 | | Executor | current npm package under `/opt/hermes-box` | `~/.executor` plus XDG directories | | ||
| 221 | | Neovim | Ubuntu package | XDG config/data/state | | ||
| 222 | |||
| 223 | Configuration lives in `box.env`. The provisioning script installs a root-owned | ||
| 224 | copy at `/etc/hermes-box.env`, so guest helpers honor `INSTALL_HERMES`, | ||
| 225 | `INSTALL_EXECUTOR`, `EXECUTOR_PORT`, and `WIRE_EXECUTOR_MCP` consistently. | ||
| 226 | New boxes default to 4 CPUs, 8 GiB memory, and a configurable 64 GiB overlay. | ||
| 227 | Optional Hermes API forwarding is disabled by default and uses per-box, | ||
| 228 | collision-checked loopback ports when enabled. Registry rows remain compatible | ||
| 229 | with legacy two-column entries. | ||
| 230 | 84 | ||
| 231 | ## Local validation | 85 | ## Local validation |
| 232 | 86 | ||
| 233 | ```bash | 87 | ```bash |
| 234 | make check | 88 | make check # syntax + shellcheck + static contracts + regressions; hermetic |
| 89 | make check-hermes-pin # network: compare HERMES_INSTALLER_SHA256 against upstream | ||
| 235 | ``` | 90 | ``` |
| 236 | 91 | ||
| 237 | This requires ShellCheck, Python 3, and jq, and runs Bash syntax checks, static | 92 | `.depot/workflows/check.yml` runs `make check` on every push and pull request |
| 238 | contract checks, protocol-health regressions, and local archive/lifecycle smoke | 93 | to `main` via Depot CI. |
| 239 | tests. It does not create a VM or contact upstream installers. `make check` is | 94 | |
| 240 | fully hermetic, so `.depot/workflows/check.yml` runs it on every push and | 95 | If an image build fails with "Hermes installer checksum verification failed", |
| 241 | pull request to `main` via Depot CI. | 96 | upstream rotated `install.sh` since the pin: run `make check-hermes-pin`, |
| 242 | 97 | review the new installer, then update `box.env` yourself. | |
| 243 | If `./box new` fails immediately with "Hermes installer checksum verification | ||
| 244 | failed", upstream has rotated `install.sh` since `HERMES_INSTALLER_SHA256` was | ||
| 245 | last pinned. Run `make check-hermes-pin` (needs network access, so it's not | ||
| 246 | part of `make check`) to confirm the drift, review the new installer, then | ||
| 247 | update `box.env` yourself — this check does not auto-update the pin. | ||
| @@ -0,0 +1,18 @@ | |||
| 1 | // Package main embeds the Docker build context (provision/, guest/, | ||
| 2 | // docker/Dockerfile + entrypoints, box.env) so tx9 ships a self-contained | ||
| 3 | // binary with no repo checkout required (decision 1, tx9-cli-design.md). | ||
| 4 | // | ||
| 5 | // This lives at the module root — not under internal/ — because go:embed | ||
| 6 | // patterns are resolved relative to the source file's directory and cannot | ||
| 7 | // reach outside it; provision/, guest/, and docker/ are siblings of this | ||
| 8 | // file, not of internal/assets. Since this file must share its directory | ||
| 9 | // with main.go (package main), and Go forbids importing a package literally | ||
| 10 | // named "main", internal/assets cannot import BuildContext directly. Instead | ||
| 11 | // main.go passes BuildContext down through internal/cli into internal/assets | ||
| 12 | // functions that accept an fs.FS parameter. | ||
| 13 | package main | ||
| 14 | |||
| 15 | import "embed" | ||
| 16 | |||
| 17 | //go:embed provision guest docker/Dockerfile docker/entrypoint.sh docker/executor-entrypoint.sh box.env | ||
| 18 | var BuildContext embed.FS | ||
| @@ -1,1109 +0,0 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # hermes-box-lite — isolated, portable agent boxes whose durable state lives in | ||
| 3 | # /data. Runtime: smolvm. Backups are encrypted state archives, not VM images. | ||
| 4 | set -euo pipefail | ||
| 5 | |||
| 6 | ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| 7 | cd "$ROOT" | ||
| 8 | # shellcheck source=box.env | ||
| 9 | source "$ROOT/box.env" | ||
| 10 | # shellcheck source=guest/lib-mcp.sh | ||
| 11 | source "$ROOT/guest/lib-mcp.sh" | ||
| 12 | |||
| 13 | REG="$ROOT/.boxes" | ||
| 14 | LOCK_ROOT="$ROOT/.box-locks" | ||
| 15 | _BOX_PASS="" | ||
| 16 | _TEMP_PATHS=() | ||
| 17 | _LOCK_DIRS=() | ||
| 18 | _PAUSED_BOXES=() | ||
| 19 | _PAUSED_RESUME_ON_CLEANUP=() | ||
| 20 | _GUEST_TEMP_BOXES=() | ||
| 21 | _GUEST_TEMP_PATHS=() | ||
| 22 | _CREATED_BOXES=() | ||
| 23 | _STOP_AFTER_BOXES=() | ||
| 24 | _PENDING_SIGNAL=0 | ||
| 25 | _LOCK_FAILURE="" | ||
| 26 | |||
| 27 | die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; } | ||
| 28 | say() { printf '\033[36m==>\033[0m %s\n' "$*"; } | ||
| 29 | warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; } | ||
| 30 | have() { command -v "$1" >/dev/null 2>&1; } | ||
| 31 | |||
| 32 | _cleanup() { | ||
| 33 | local status=$? n path index | ||
| 34 | trap - EXIT HUP INT TERM | ||
| 35 | set +e | ||
| 36 | for index in "${!_GUEST_TEMP_PATHS[@]}"; do | ||
| 37 | n="${_GUEST_TEMP_BOXES[$index]}" | ||
| 38 | path="${_GUEST_TEMP_PATHS[$index]}" | ||
| 39 | [[ -n "$n" && -n "$path" ]] || continue | ||
| 40 | smolvm machine exec --name "$n" -- rm -f -- "$path" >/dev/null 2>&1 || true | ||
| 41 | done | ||
| 42 | for path in "${_TEMP_PATHS[@]:-}"; do | ||
| 43 | [[ -n "$path" ]] && rm -f -- "$path" | ||
| 44 | done | ||
| 45 | for n in "${_CREATED_BOXES[@]:-}"; do | ||
| 46 | [[ -n "$n" ]] || continue | ||
| 47 | _rollback_created "$n" || true | ||
| 48 | done | ||
| 49 | for index in "${!_PAUSED_BOXES[@]}"; do | ||
| 50 | n="${_PAUSED_BOXES[$index]}" | ||
| 51 | [[ -n "$n" && "${_PAUSED_RESUME_ON_CLEANUP[$index]}" == 1 ]] || continue | ||
| 52 | _guest_hb "$n" resume >/dev/null 2>&1 || true | ||
| 53 | done | ||
| 54 | for n in "${_STOP_AFTER_BOXES[@]:-}"; do | ||
| 55 | [[ -n "$n" ]] && smolvm machine stop --name "$n" >/dev/null 2>&1 || true | ||
| 56 | done | ||
| 57 | for path in "${_LOCK_DIRS[@]:-}"; do | ||
| 58 | [[ -n "$path" ]] || continue | ||
| 59 | if [[ -r "$path/pid" && "$(cat "$path/pid")" == "$$" ]]; then rm -f "$path/pid"; fi | ||
| 60 | rmdir "$path" >/dev/null 2>&1 || true | ||
| 61 | done | ||
| 62 | exit "$status" | ||
| 63 | } | ||
| 64 | |||
| 65 | _install_cleanup_traps() { | ||
| 66 | trap _cleanup EXIT | ||
| 67 | _install_signal_traps | ||
| 68 | } | ||
| 69 | |||
| 70 | _install_signal_traps() { | ||
| 71 | trap 'exit 129' HUP | ||
| 72 | trap 'exit 130' INT | ||
| 73 | trap 'exit 143' TERM | ||
| 74 | } | ||
| 75 | |||
| 76 | _defer_signals() { | ||
| 77 | _PENDING_SIGNAL=0 | ||
| 78 | trap '_PENDING_SIGNAL=129' HUP | ||
| 79 | trap '_PENDING_SIGNAL=130' INT | ||
| 80 | trap '_PENDING_SIGNAL=143' TERM | ||
| 81 | } | ||
| 82 | |||
| 83 | _honor_pending_signal() { | ||
| 84 | local status="$_PENDING_SIGNAL" | ||
| 85 | _PENDING_SIGNAL=0 | ||
| 86 | [[ "$status" == 0 ]] || exit "$status" | ||
| 87 | } | ||
| 88 | |||
| 89 | _track_temp() { _TEMP_PATHS+=("$1"); } | ||
| 90 | |||
| 91 | _track_guest_temp() { | ||
| 92 | _GUEST_TEMP_BOXES+=("$1") | ||
| 93 | _GUEST_TEMP_PATHS+=("$2") | ||
| 94 | } | ||
| 95 | |||
| 96 | _untrack_guest_temp() { | ||
| 97 | local n="$1" path="$2" index | ||
| 98 | for index in "${!_GUEST_TEMP_PATHS[@]}"; do | ||
| 99 | if [[ "${_GUEST_TEMP_BOXES[$index]}" == "$n" && "${_GUEST_TEMP_PATHS[$index]}" == "$path" ]]; then | ||
| 100 | _GUEST_TEMP_BOXES[index]="" | ||
| 101 | _GUEST_TEMP_PATHS[index]="" | ||
| 102 | fi | ||
| 103 | done | ||
| 104 | } | ||
| 105 | |||
| 106 | _track_stop_after() { _STOP_AFTER_BOXES+=("$1"); } | ||
| 107 | _clear_stop_after() { | ||
| 108 | local n="$1" index | ||
| 109 | for index in "${!_STOP_AFTER_BOXES[@]}"; do | ||
| 110 | [[ "${_STOP_AFTER_BOXES[$index]}" == "$n" ]] && _STOP_AFTER_BOXES[index]="" | ||
| 111 | done | ||
| 112 | } | ||
| 113 | |||
| 114 | _mark_created() { _CREATED_BOXES+=("$1"); } | ||
| 115 | |||
| 116 | _clear_created() { | ||
| 117 | local n="$1" index | ||
| 118 | for index in "${!_CREATED_BOXES[@]}"; do | ||
| 119 | [[ "${_CREATED_BOXES[$index]}" == "$n" ]] && _CREATED_BOXES[index]="" | ||
| 120 | done | ||
| 121 | } | ||
| 122 | |||
| 123 | _require() { have "$1" || die "$1 not found on PATH"; } | ||
| 124 | |||
| 125 | _host_preflight() { | ||
| 126 | local mode="${1:-full}" cmd requirements=() | ||
| 127 | case "$mode" in | ||
| 128 | basic) requirements=(awk smolvm) ;; | ||
| 129 | provision) requirements=(awk smolvm tar) ;; | ||
| 130 | health) requirements=(awk curl jq mktemp smolvm) ;; | ||
| 131 | import) requirements=(awk jq mktemp python3 smolvm) ;; | ||
| 132 | archive) requirements=(awk gpg grep mktemp python3 tar) ;; | ||
| 133 | full) requirements=(awk curl gpg grep jq mktemp python3 smolvm tar) ;; | ||
| 134 | *) die "unknown host preflight mode '$mode'" ;; | ||
| 135 | esac | ||
| 136 | for cmd in "${requirements[@]}"; do _require "$cmd"; done | ||
| 137 | } | ||
| 138 | |||
| 139 | _validate_name() { | ||
| 140 | local n="$1" | ||
| 141 | [[ "$n" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || | ||
| 142 | die "invalid box name '$n' (use 1-63 letters, numbers, dots, underscores, or dashes)" | ||
| 143 | } | ||
| 144 | |||
| 145 | _try_acquire_lock() { | ||
| 146 | local key="$1" dir="$LOCK_ROOT/$1.lock" | ||
| 147 | _LOCK_FAILURE="" | ||
| 148 | if ! mkdir -p "$LOCK_ROOT"; then | ||
| 149 | _LOCK_FAILURE="could not create the lock directory" | ||
| 150 | return 1 | ||
| 151 | fi | ||
| 152 | if ! mkdir "$dir" 2>/dev/null; then | ||
| 153 | local owner="" | ||
| 154 | [[ -r "$dir/pid" ]] && owner="$(cat "$dir/pid")" | ||
| 155 | if [[ "$owner" =~ ^[0-9]+$ ]] && ! kill -0 "$owner" 2>/dev/null; then | ||
| 156 | rm -rf "$dir" | ||
| 157 | if ! mkdir "$dir" 2>/dev/null; then | ||
| 158 | _LOCK_FAILURE="could not recover stale '$key' lock" | ||
| 159 | return 1 | ||
| 160 | fi | ||
| 161 | else | ||
| 162 | _LOCK_FAILURE="another operation already holds the '$key' lock${owner:+ (pid $owner)}" | ||
| 163 | return 1 | ||
| 164 | fi | ||
| 165 | fi | ||
| 166 | if ! printf '%s\n' "$$" >"$dir/pid"; then | ||
| 167 | rm -f -- "$dir/pid" | ||
| 168 | rmdir "$dir" >/dev/null 2>&1 || true | ||
| 169 | _LOCK_FAILURE="could not record ownership of the '$key' lock" | ||
| 170 | return 1 | ||
| 171 | fi | ||
| 172 | _LOCK_DIRS+=("$dir") | ||
| 173 | } | ||
| 174 | |||
| 175 | _acquire_lock() { | ||
| 176 | local key="$1" | ||
| 177 | _try_acquire_lock "$key" || die "$_LOCK_FAILURE" | ||
| 178 | } | ||
| 179 | |||
| 180 | _release_lock() { | ||
| 181 | local dir="$LOCK_ROOT/$1.lock" | ||
| 182 | rm -f "$dir/pid" | ||
| 183 | rmdir "$dir" >/dev/null 2>&1 || true | ||
| 184 | } | ||
| 185 | |||
| 186 | # One line per managed box: name, Executor host port, Hermes API host port, | ||
| 187 | # Hermes API guest bridge port. Legacy two-column rows remain valid. | ||
| 188 | # metadata; the VM remains the source of truth for whether an instance exists. | ||
| 189 | _reg_add() { | ||
| 190 | touch "$REG" | ||
| 191 | _reg_del "$1" | ||
| 192 | printf '%s\t%s\t%s\t%s\n' "$1" "${2:-}" "${3:-}" "${4:-}" >>"$REG" | ||
| 193 | } | ||
| 194 | _reg_del() { | ||
| 195 | [[ -f "$REG" ]] || return 0 | ||
| 196 | awk -F'\t' -v n="$1" '$1!=n' "$REG" >"$REG.tmp" | ||
| 197 | mv "$REG.tmp" "$REG" | ||
| 198 | } | ||
| 199 | _reg_port() { [[ -f "$REG" ]] && awk -F'\t' -v n="$1" '$1==n{print $2}' "$REG"; } | ||
| 200 | _reg_exists() { [[ -f "$REG" ]] && awk -F'\t' -v n="$1" '$1==n{found=1} END{exit !found}' "$REG"; } | ||
| 201 | _reg_api_port() { [[ -f "$REG" ]] && awk -F'\t' -v n="$1" '$1==n{print $3}' "$REG"; } | ||
| 202 | _reg_api_guest_port() { [[ -f "$REG" ]] && awk -F'\t' -v n="$1" '$1==n{print $4}' "$REG"; } | ||
| 203 | |||
| 204 | _machine_exists() { smolvm machine status --name "$1" >/dev/null 2>&1; } | ||
| 205 | _machine_running() { | ||
| 206 | local status | ||
| 207 | status="$(smolvm machine status --name "$1" 2>/dev/null)" && grep -q ': running' <<<"$status" | ||
| 208 | } | ||
| 209 | |||
| 210 | _tcp_port_open() { | ||
| 211 | local host="$1" port="$2" | ||
| 212 | (exec 3<>"/dev/tcp/$host/$port") 2>/dev/null | ||
| 213 | } | ||
| 214 | |||
| 215 | _port_free() { | ||
| 216 | awk -F'\t' -v p="$1" '$2==p || $3==p || $4==p{f=1} END{exit !f}' "$REG" 2>/dev/null && return 1 | ||
| 217 | _tcp_port_open 127.0.0.1 "$1" && return 1 | ||
| 218 | _tcp_port_open ::1 "$1" && return 1 | ||
| 219 | return 0 | ||
| 220 | } | ||
| 221 | |||
| 222 | _wait_mcp_ready() { | ||
| 223 | local url="$1" token="$2" attempt | ||
| 224 | for ((attempt = 0; attempt < 30; attempt++)); do | ||
| 225 | _mcp_initialize "$url" "$token" >/dev/null 2>&1 && return 0 | ||
| 226 | sleep 1 | ||
| 227 | done | ||
| 228 | return 1 | ||
| 229 | } | ||
| 230 | |||
| 231 | _hermes_api_healthy() { | ||
| 232 | local port="$1" body code | ||
| 233 | body="$(mktemp)" || return 1 | ||
| 234 | _track_temp "$body" | ||
| 235 | code="$(curl -sS --connect-timeout 2 --max-time 5 -o "$body" -w '%{http_code}' \ | ||
| 236 | "http://127.0.0.1:${port}/health")" || return 1 | ||
| 237 | [[ "$code" =~ ^2[0-9][0-9]$ ]] || return 1 | ||
| 238 | jq -e '.status == "ok" and .platform == "hermes-agent"' "$body" >/dev/null 2>&1 | ||
| 239 | } | ||
| 240 | |||
| 241 | _assign_port() { | ||
| 242 | local lo="$1" hi="$2" label="$3" forbidden="${4:-}" p attempt | ||
| 243 | [[ "$lo" =~ ^[0-9]+$ && "$hi" =~ ^[0-9]+$ && "$lo" -ge 1024 && "$hi" -le 65535 && "$lo" -le "$hi" ]] || | ||
| 244 | die "invalid ${label} port range ${lo}-${hi}" | ||
| 245 | for ((attempt = 0; attempt < 300; attempt++)); do | ||
| 246 | p=$((lo + RANDOM % (hi - lo + 1))) | ||
| 247 | [[ "$p" != "$forbidden" ]] && _port_free "$p" && { printf '%s' "$p"; return 0; } | ||
| 248 | done | ||
| 249 | die "no free host port in ${lo}-${hi}" | ||
| 250 | } | ||
| 251 | |||
| 252 | _validate_resources() { | ||
| 253 | BOX_CPUS="${BOX_CPUS:-4}" | ||
| 254 | BOX_MEM_MIB="${BOX_MEM_MIB:-8192}" | ||
| 255 | BOX_OVERLAY_GIB="${BOX_OVERLAY_GIB:-64}" | ||
| 256 | BOX_STORAGE_GIB="${BOX_STORAGE_GIB:-}" | ||
| 257 | [[ "${BOX_CPUS:-}" =~ ^[1-9][0-9]*$ && "${BOX_CPUS}" -le 128 ]] || die "BOX_CPUS must be 1-128" | ||
| 258 | [[ "${BOX_MEM_MIB:-}" =~ ^[1-9][0-9]*$ && "${BOX_MEM_MIB}" -ge 512 ]] || die "BOX_MEM_MIB must be at least 512" | ||
| 259 | [[ "${BOX_OVERLAY_GIB:-}" =~ ^[1-9][0-9]*$ ]] || die "BOX_OVERLAY_GIB must be a positive integer" | ||
| 260 | [[ -z "${BOX_STORAGE_GIB:-}" || "${BOX_STORAGE_GIB}" =~ ^[1-9][0-9]*$ ]] || die "BOX_STORAGE_GIB must be empty or a positive integer" | ||
| 261 | } | ||
| 262 | |||
| 263 | _validate_bundle() { | ||
| 264 | local bundle="${HERMES_GIT_BUNDLE:-}" | ||
| 265 | [[ -n "$bundle" ]] || return 0 | ||
| 266 | _require git | ||
| 267 | [[ "$bundle" != /* && "$bundle" != *..* && "$bundle" == artifacts/* ]] || | ||
| 268 | die "HERMES_GIT_BUNDLE must be a safe repository-relative path beneath artifacts/" | ||
| 269 | [[ -f "$ROOT/$bundle" ]] || die "Hermes Git bundle not found: $ROOT/$bundle" | ||
| 270 | git bundle verify "$ROOT/$bundle" >/dev/null 2>&1 || die "invalid Hermes Git bundle: $bundle" | ||
| 271 | } | ||
| 272 | |||
| 273 | _resolve_passphrase() { | ||
| 274 | local confirm="${1:-}" p1 p2 | ||
| 275 | [[ -n "$_BOX_PASS" ]] && return | ||
| 276 | if [[ -n "${BOX_PASSPHRASE_FILE:-}" ]]; then | ||
| 277 | [[ -r "$BOX_PASSPHRASE_FILE" ]] || die "BOX_PASSPHRASE_FILE is not readable" | ||
| 278 | IFS= read -r _BOX_PASS <"$BOX_PASSPHRASE_FILE" || true | ||
| 279 | [[ -n "$_BOX_PASS" ]] || die "BOX_PASSPHRASE_FILE is empty" | ||
| 280 | return | ||
| 281 | fi | ||
| 282 | if [[ -n "${BOX_PASSPHRASE:-}" ]]; then | ||
| 283 | _BOX_PASS="$BOX_PASSPHRASE" | ||
| 284 | return | ||
| 285 | fi | ||
| 286 | [[ -r /dev/tty ]] || die "no BOX_PASSPHRASE set and no terminal to prompt on" | ||
| 287 | printf 'Passphrase: ' >/dev/tty | ||
| 288 | read -rs p1 </dev/tty | ||
| 289 | printf '\n' >/dev/tty | ||
| 290 | if [[ "$confirm" == confirm ]]; then | ||
| 291 | printf 'Confirm passphrase: ' >/dev/tty | ||
| 292 | read -rs p2 </dev/tty | ||
| 293 | printf '\n' >/dev/tty | ||
| 294 | [[ "$p1" == "$p2" ]] || die "passphrases do not match" | ||
| 295 | fi | ||
| 296 | [[ -n "$p1" ]] || die "empty passphrase" | ||
| 297 | _BOX_PASS="$p1" | ||
| 298 | } | ||
| 299 | |||
| 300 | gpg_enc() { | ||
| 301 | _resolve_passphrase confirm | ||
| 302 | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 3 \ | ||
| 303 | --symmetric --cipher-algo AES256 -o "$1" 3<<<"$_BOX_PASS" | ||
| 304 | } | ||
| 305 | |||
| 306 | gpg_dec() { | ||
| 307 | _resolve_passphrase | ||
| 308 | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 3 -d "$1" 3<<<"$_BOX_PASS" | ||
| 309 | } | ||
| 310 | |||
| 311 | _validate_archive() { | ||
| 312 | python3 - "$1" <<'PY' | ||
| 313 | import sys | ||
| 314 | import tarfile | ||
| 315 | from pathlib import PurePosixPath | ||
| 316 | |||
| 317 | def safe_text(value, label): | ||
| 318 | if "\x00" in value or any(ord(char) < 32 or ord(char) == 127 for char in value): | ||
| 319 | raise ValueError(f"{label} contains control characters") | ||
| 320 | |||
| 321 | def link_parts(value): | ||
| 322 | safe_text(value, "archive link target") | ||
| 323 | path = PurePosixPath(value) | ||
| 324 | if path.is_absolute(): | ||
| 325 | raise ValueError("archive link target is absolute") | ||
| 326 | return [part for part in path.parts if part not in {"", "."}] | ||
| 327 | |||
| 328 | def validate_link_target(member, key): | ||
| 329 | parts = link_parts(member.linkname) | ||
| 330 | if not parts: | ||
| 331 | raise ValueError("archive link target is empty") | ||
| 332 | resolved = key.split("/")[:-1] if member.issym() else [] | ||
| 333 | for part in parts: | ||
| 334 | if part == "..": | ||
| 335 | if not resolved: | ||
| 336 | raise ValueError("archive link target lexically escapes extraction root") | ||
| 337 | resolved.pop() | ||
| 338 | else: | ||
| 339 | resolved.append(part) | ||
| 340 | |||
| 341 | try: | ||
| 342 | with tarfile.open(sys.argv[1], "r:gz") as archive: | ||
| 343 | members = archive.getmembers() | ||
| 344 | if not members: | ||
| 345 | raise ValueError("archive is empty") | ||
| 346 | by_name = {} | ||
| 347 | for member in members: | ||
| 348 | name = member.name | ||
| 349 | safe_text(name, "archive path") | ||
| 350 | path = PurePosixPath(name) | ||
| 351 | if path.is_absolute() or any(part == ".." for part in path.parts): | ||
| 352 | raise ValueError("archive path escapes its extraction root") | ||
| 353 | parts = tuple(part for part in path.parts if part not in {"", "."}) | ||
| 354 | key = "/".join(parts) | ||
| 355 | if key in by_name: | ||
| 356 | raise ValueError(f"archive contains duplicate path: {key or '.'}") | ||
| 357 | if not (member.isfile() or member.isdir() or member.issym() or member.islnk()): | ||
| 358 | raise ValueError("archive contains a device, FIFO, socket, or unknown special file") | ||
| 359 | if not key and not member.isdir(): | ||
| 360 | raise ValueError("archive root member must be a real directory") | ||
| 361 | if member.issym() or member.islnk(): | ||
| 362 | validate_link_target(member, key) | ||
| 363 | by_name[key] = member | ||
| 364 | |||
| 365 | for key in by_name: | ||
| 366 | parts = key.split("/") if key else [] | ||
| 367 | for length in range(1, len(parts)): | ||
| 368 | parent = by_name.get("/".join(parts[:length])) | ||
| 369 | if parent is not None and (parent.issym() or parent.islnk()): | ||
| 370 | raise ValueError(f"archive member is nested beneath a link: {key}") | ||
| 371 | |||
| 372 | for required in ("home", "home/agent"): | ||
| 373 | entry = by_name.get(required) | ||
| 374 | if entry is None or not entry.isdir() or entry.issym() or entry.islnk(): | ||
| 375 | raise ValueError(f"{required} must be a real directory") | ||
| 376 | |||
| 377 | def resolve(key): | ||
| 378 | pending = key.split("/") if key else [] | ||
| 379 | resolved = [] | ||
| 380 | seen_states = set() | ||
| 381 | steps = 0 | ||
| 382 | while pending: | ||
| 383 | steps += 1 | ||
| 384 | if steps > len(by_name) * 8 + 32: | ||
| 385 | raise ValueError("archive link chain exceeds safety bound") | ||
| 386 | state = (tuple(resolved), tuple(pending)) | ||
| 387 | if state in seen_states: | ||
| 388 | raise ValueError("archive link cycle detected") | ||
| 389 | seen_states.add(state) | ||
| 390 | part = pending.pop(0) | ||
| 391 | if part in {"", "."}: | ||
| 392 | continue | ||
| 393 | if part == "..": | ||
| 394 | if not resolved: | ||
| 395 | raise ValueError("archive link target escapes extraction root") | ||
| 396 | resolved.pop() | ||
| 397 | continue | ||
| 398 | candidate = "/".join((*resolved, part)) | ||
| 399 | entry = by_name.get(candidate) | ||
| 400 | if entry is not None and (entry.issym() or entry.islnk()): | ||
| 401 | base = candidate.split("/")[:-1] if entry.issym() else [] | ||
| 402 | pending = base + link_parts(entry.linkname) + pending | ||
| 403 | resolved = [] | ||
| 404 | continue | ||
| 405 | if pending and (entry is None or not entry.isdir()): | ||
| 406 | raise ValueError(f"archive path resolves through missing or non-directory member: {candidate}") | ||
| 407 | resolved.append(part) | ||
| 408 | final = "/".join(resolved) | ||
| 409 | entry = by_name.get(final) | ||
| 410 | if entry is None: | ||
| 411 | raise ValueError(f"archive link resolves to missing member: {final or '.'}") | ||
| 412 | return entry | ||
| 413 | |||
| 414 | for key, member in by_name.items(): | ||
| 415 | if not key: | ||
| 416 | continue | ||
| 417 | parent = key.rpartition("/")[0] | ||
| 418 | if parent and not resolve(parent).isdir(): | ||
| 419 | raise ValueError(f"archive member parent is not a directory: {key}") | ||
| 420 | if member.issym() or member.islnk(): | ||
| 421 | target = resolve(key) | ||
| 422 | if member.islnk() and not target.isfile(): | ||
| 423 | raise ValueError("archive hardlink does not resolve to a regular file") | ||
| 424 | except (OSError, tarfile.TarError, ValueError) as error: | ||
| 425 | print(f"unsafe archive: {error}", file=sys.stderr) | ||
| 426 | raise SystemExit(1) | ||
| 427 | PY | ||
| 428 | } | ||
| 429 | |||
| 430 | _guest_hb() { | ||
| 431 | local n="$1" | ||
| 432 | shift | ||
| 433 | smolvm machine exec --name "$n" -- \ | ||
| 434 | setpriv --reuid=agent --regid=agent --init-groups \ | ||
| 435 | env HOME=/data/home/agent bash --noprofile --norc -c \ | ||
| 436 | '. /etc/profile.d/hermes-box.sh; exec hb "$@"' hb "$@" | ||
| 437 | } | ||
| 438 | |||
| 439 | _pause_box() { | ||
| 440 | local n="$1" resume_on_cleanup="${2:-1}" | ||
| 441 | if _guest_hb "$n" is-paused >/dev/null 2>&1; then return; fi | ||
| 442 | _PAUSED_BOXES+=("$n") | ||
| 443 | _PAUSED_RESUME_ON_CLEANUP+=("$resume_on_cleanup") | ||
| 444 | if ! _guest_hb "$n" pause; then return 1; fi | ||
| 445 | _guest_hb "$n" is-paused >/dev/null 2>&1 | ||
| 446 | } | ||
| 447 | |||
| 448 | _resume_box() { | ||
| 449 | local n="$1" index | ||
| 450 | for index in "${!_PAUSED_BOXES[@]}"; do | ||
| 451 | if [[ "${_PAUSED_BOXES[$index]}" == "$n" ]]; then | ||
| 452 | _guest_hb "$n" resume || return 1 | ||
| 453 | _PAUSED_BOXES[index]="" | ||
| 454 | _PAUSED_RESUME_ON_CLEANUP[index]="" | ||
| 455 | return | ||
| 456 | fi | ||
| 457 | done | ||
| 458 | } | ||
| 459 | |||
| 460 | _create_base() { | ||
| 461 | local n="$1" port="" api_port="" api_guest_port="" wl_port create_status opts=() resources=() | ||
| 462 | _validate_resources | ||
| 463 | _validate_bundle | ||
| 464 | _acquire_lock registry | ||
| 465 | ! _reg_exists "$n" || { _release_lock registry; die "box '$n' is already registered"; } | ||
| 466 | if _machine_exists "$n"; then | ||
| 467 | _release_lock registry | ||
| 468 | die "smolvm machine '$n' already exists" | ||
| 469 | fi | ||
| 470 | if [[ "${INSTALL_EXECUTOR:-0}" == 1 && "${EXPOSE_EXECUTOR_WEB:-0}" == 1 ]]; then | ||
| 471 | port="$(_assign_port "${EXECUTOR_PORT_LO:-4790}" "${EXECUTOR_PORT_HI:-4999}" executor)" || { _release_lock registry; return 1; } | ||
| 472 | opts+=(-p "${port}:${port}") | ||
| 473 | say "Assigned host port ${port} -> executor (http://localhost:${port})" | ||
| 474 | fi | ||
| 475 | if [[ "${INSTALL_HERMES:-0}" == 1 && "${EXPOSE_HERMES_API:-0}" == 1 ]]; then | ||
| 476 | api_port="$(_assign_port "${HERMES_API_PORT_LO:-8650}" "${HERMES_API_PORT_HI:-8850}" 'Hermes API' "$port")" || { _release_lock registry; return 1; } | ||
| 477 | api_guest_port="$api_port" | ||
| 478 | opts+=(-p "${api_port}:${api_guest_port}") | ||
| 479 | say "Assigned host port ${api_port} -> Hermes API (http://localhost:${api_port})" | ||
| 480 | fi | ||
| 481 | resources=(--cpus "$BOX_CPUS" --mem "$BOX_MEM_MIB" --overlay "$BOX_OVERLAY_GIB") | ||
| 482 | [[ -z "${BOX_STORAGE_GIB:-}" ]] || resources+=(--storage "$BOX_STORAGE_GIB") | ||
| 483 | wl_port="${port:-4788}" | ||
| 484 | _defer_signals | ||
| 485 | # No `exec` into hb-workload: if the loop process itself dies (OOM-killed, a | ||
| 486 | # bug not guarded by its `set -u`), nothing else in the VM restarts it, so | ||
| 487 | # this outer loop is the supervisor. hb-workload's own internals (gateway, | ||
| 488 | # socat bridges) are reconciled every 20s once it's running again. | ||
| 489 | if smolvm machine create --net "${opts[@]}" "${resources[@]}" --name "$n" --image "$BASE_IMAGE" \ | ||
| 490 | -- bash -c "while [ ! -x /opt/hermes-box/bin/hb-workload ]; do sleep 2; done | ||
| 491 | while true; do runuser -u agent -- env HOME=/data/home/agent /opt/hermes-box/bin/hb-workload ${wl_port} ${api_guest_port:-0}; sleep 2; done"; then | ||
| 492 | create_status=0 | ||
| 493 | else | ||
| 494 | create_status=$? | ||
| 495 | fi | ||
| 496 | if [[ "$create_status" != 0 ]]; then | ||
| 497 | _install_signal_traps | ||
| 498 | _release_lock registry | ||
| 499 | warn "smolvm create failed for '$n'; ownership is unknown, so no VM will be deleted automatically" | ||
| 500 | warn "inspect with: smolvm machine status --name '$n'" | ||
| 501 | _honor_pending_signal | ||
| 502 | return 1 | ||
| 503 | fi | ||
| 504 | _mark_created "$n" | ||
| 505 | if ! _reg_add "$n" "$port" "$api_port" "$api_guest_port"; then | ||
| 506 | _install_signal_traps | ||
| 507 | _release_lock registry | ||
| 508 | _honor_pending_signal | ||
| 509 | return 1 | ||
| 510 | fi | ||
| 511 | _install_signal_traps | ||
| 512 | _release_lock registry | ||
| 513 | _honor_pending_signal | ||
| 514 | smolvm machine start --name "$n" | ||
| 515 | } | ||
| 516 | |||
| 517 | _provision_into() { | ||
| 518 | local n="$1" mode="${2:-full}" log_file="${3:-}" contexts=(provision guest box.env) cmd | ||
| 519 | if [[ "$mode" == full ]]; then | ||
| 520 | _validate_bundle | ||
| 521 | [[ -z "${HERMES_GIT_BUNDLE:-}" ]] || contexts+=("$HERMES_GIT_BUNDLE") | ||
| 522 | fi | ||
| 523 | say "Streaming provision context into '$n'" | ||
| 524 | if ! COPYFILE_DISABLE=1 tar czf - -C "$ROOT" "${contexts[@]}" \ | ||
| 525 | | smolvm machine exec -i --name "$n" -- bash -lc 'cat >/tmp/ctx.tgz'; then | ||
| 526 | return 1 | ||
| 527 | fi | ||
| 528 | if [[ "$mode" == assets ]]; then | ||
| 529 | say "Refreshing managed guest assets" | ||
| 530 | else | ||
| 531 | say "Provisioning node/uv, claude, codex, hermes, executor (a few minutes)…" | ||
| 532 | fi | ||
| 533 | cmd="set -e | ||
| 534 | rm -rf /tmp/ctx && mkdir -p /tmp/ctx | ||
| 535 | tar xzf /tmp/ctx.tgz -C /tmp/ctx | ||
| 536 | bash /tmp/ctx/provision/provision.sh '$mode'" | ||
| 537 | # Optionally tee the transcript to a host-side file so a caller can persist | ||
| 538 | # it as failure evidence before rollback deletes the only other copy. | ||
| 539 | if [[ -n "$log_file" ]]; then | ||
| 540 | smolvm machine exec --name "$n" -- bash -lc "$cmd" 2>&1 | tee "$log_file" | ||
| 541 | else | ||
| 542 | smolvm machine exec --name "$n" -- bash -lc "$cmd" | ||
| 543 | fi | ||
| 544 | } | ||
| 545 | |||
| 546 | _prepare_runtime() { | ||
| 547 | local n="$1" | ||
| 548 | _guest_hb "$n" up || return 1 | ||
| 549 | _guest_hb "$n" wire-once || return 1 | ||
| 550 | _guest_hb "$n" doctor | ||
| 551 | } | ||
| 552 | |||
| 553 | # Best-effort: a failure saving the transcript (full disk, read-only checkout) | ||
| 554 | # must never itself abort the caller's failure-handling path under set -e — | ||
| 555 | # that would skip keep-on-failure's _clear_created and let the EXIT trap roll | ||
| 556 | # the box back anyway, defeating the one thing the operator needed most. | ||
| 557 | _save_failed_transcript() { | ||
| 558 | local n="$1" log_file="$2" failed_log | ||
| 559 | [[ -s "$log_file" ]] || return 0 | ||
| 560 | if ! mkdir -p backups/.failed; then | ||
| 561 | warn "could not create backups/.failed; provisioning transcript was not saved" | ||
| 562 | return 0 | ||
| 563 | fi | ||
| 564 | failed_log="backups/.failed/${n}-$(date -u +%Y%m%dT%H%M%SZ).log" | ||
| 565 | if cp "$log_file" "$failed_log"; then | ||
| 566 | warn "provisioning transcript saved to $failed_log" | ||
| 567 | else | ||
| 568 | warn "could not save provisioning transcript to $failed_log" | ||
| 569 | fi | ||
| 570 | } | ||
| 571 | |||
| 572 | _rollback_created() { | ||
| 573 | local n="$1" registry_lock="$LOCK_ROOT/registry.lock" owns_lock=0 | ||
| 574 | warn "rolling back incomplete box '$n'" | ||
| 575 | if ! smolvm machine delete --name "$n" -f >/dev/null 2>&1; then | ||
| 576 | warn "could not remove incomplete box '$n'; registry metadata was preserved" | ||
| 577 | warn "recover with: ./box rm '$n' --force" | ||
| 578 | return 1 | ||
| 579 | fi | ||
| 580 | if _machine_exists "$n"; then | ||
| 581 | warn "could not remove incomplete box '$n'; registry metadata was preserved" | ||
| 582 | warn "recover with: ./box rm '$n' --force" | ||
| 583 | return 1 | ||
| 584 | fi | ||
| 585 | if [[ -r "$registry_lock/pid" && "$(cat "$registry_lock/pid" 2>/dev/null)" == "$$" ]]; then | ||
| 586 | owns_lock=1 | ||
| 587 | elif _try_acquire_lock registry; then | ||
| 588 | owns_lock=1 | ||
| 589 | fi | ||
| 590 | if [[ "$owns_lock" != 1 ]]; then | ||
| 591 | warn "removed VM '$n', but the registry lock is busy; metadata was preserved" | ||
| 592 | warn "after the other operation finishes, remove the stale '$n' entry from $REG" | ||
| 593 | return 1 | ||
| 594 | fi | ||
| 595 | if ! _reg_del "$n"; then | ||
| 596 | warn "removed VM '$n', but failed to update $REG" | ||
| 597 | _release_lock registry | ||
| 598 | return 1 | ||
| 599 | fi | ||
| 600 | _release_lock registry | ||
| 601 | return 0 | ||
| 602 | } | ||
| 603 | |||
| 604 | cmd_new() { | ||
| 605 | local n="${1:?usage: box new <name> [--keep-on-failure]}" keep_on_failure=0 stage="" log_file | ||
| 606 | [[ "${2:-}" != --keep-on-failure && "${BOX_KEEP_ON_FAILURE:-0}" != 1 ]] || keep_on_failure=1 | ||
| 607 | _validate_name "$n" | ||
| 608 | _host_preflight provision | ||
| 609 | _acquire_lock "$n" | ||
| 610 | say "Creating instance '$n' from $BASE_IMAGE" | ||
| 611 | log_file="$(mktemp)" | ||
| 612 | _track_temp "$log_file" | ||
| 613 | if ! _create_base "$n"; then | ||
| 614 | stage="creating the base VM" | ||
| 615 | elif ! _provision_into "$n" full "$log_file"; then | ||
| 616 | stage="provisioning" | ||
| 617 | elif ! _prepare_runtime "$n"; then | ||
| 618 | stage="preparing the runtime (gateway start, MCP wiring, doctor)" | ||
| 619 | fi | ||
| 620 | if [[ -n "$stage" ]]; then | ||
| 621 | _save_failed_transcript "$n" "$log_file" | ||
| 622 | if [[ "$keep_on_failure" == 1 ]]; then | ||
| 623 | _clear_created "$n" | ||
| 624 | warn "leaving '$n' in place for inspection (--keep-on-failure / BOX_KEEP_ON_FAILURE=1)" | ||
| 625 | warn "inspect with: smolvm machine exec --name '$n' -- bash" | ||
| 626 | warn "remove when done with: ./box rm '$n' --force" | ||
| 627 | fi | ||
| 628 | die "box creation failed while $stage" | ||
| 629 | fi | ||
| 630 | _clear_created "$n" | ||
| 631 | local port | ||
| 632 | port="$(_reg_port "$n")" | ||
| 633 | say "Ready. Enter with: ./box enter $n" | ||
| 634 | [[ -n "$port" ]] && say "Executor web: http://localhost:${port} (./box open $n)" | ||
| 635 | say "Authenticate inside, then ./box save $n to capture the state." | ||
| 636 | } | ||
| 637 | |||
| 638 | cmd_enter() { | ||
| 639 | local n="${1:?usage: box enter <name>}" | ||
| 640 | _validate_name "$n" | ||
| 641 | _host_preflight basic | ||
| 642 | smolvm machine start --name "$n" >/dev/null | ||
| 643 | exec smolvm machine exec -it --name "$n" -- login -f agent | ||
| 644 | } | ||
| 645 | |||
| 646 | cmd_save() { | ||
| 647 | local n="${1:?usage: box save <name> [out.tar.gz.gpg]}" ts out raw encrypted guest_tmp | ||
| 648 | _validate_name "$n" | ||
| 649 | _host_preflight full | ||
| 650 | _acquire_lock "$n" | ||
| 651 | ts="$(date +%Y%m%d-%H%M%S)" | ||
| 652 | out="${2:-backups/${n}-${ts}.tar.gz.gpg}" | ||
| 653 | [[ ! -e "$out" ]] || die "refusing to overwrite existing backup: $out" | ||
| 654 | mkdir -p "$(dirname "$out")" | ||
| 655 | raw="$(mktemp)" | ||
| 656 | encrypted="$(mktemp "${out}.tmp.XXXXXX")" | ||
| 657 | guest_tmp="/root/hb-data-${ts}-$$-${RANDOM}.tgz" | ||
| 658 | _track_temp "$raw" | ||
| 659 | _track_temp "$encrypted" | ||
| 660 | _track_guest_temp "$n" "$guest_tmp" | ||
| 661 | |||
| 662 | say "Quiescing Hermes and Executor inside '$n'" | ||
| 663 | _pause_box "$n" | ||
| 664 | _guest_hb "$n" verify-state --checkpoint >/dev/null | ||
| 665 | _guest_hb "$n" write-manifest | ||
| 666 | say "Archiving /data inside '$n'" | ||
| 667 | # shellcheck disable=SC2016 # expanded by the guest shell; archive is positional | ||
| 668 | smolvm machine exec --name "$n" -- bash -c \ | ||
| 669 | 'set -euo pipefail; umask 077; archive=$1; sync | ||
| 670 | tar czf "$archive" -C /data \ | ||
| 671 | --exclude="./home/agent/.config/hermes-box/quiesced" \ | ||
| 672 | --exclude="./home/agent/.codex/tmp" \ | ||
| 673 | --exclude="./home/agent/.hermes/tmp" \ | ||
| 674 | --exclude="*/node_modules" --exclude="*/.venv" --exclude="*/venv" \ | ||
| 675 | --exclude="*/__pycache__" --exclude="*/.cache" \ | ||
| 676 | --exclude="*.pyc" --exclude="*.pyo" \ | ||
| 677 | --exclude="*/.hermes/*.db-wal" --exclude="*/.hermes/*.db-shm" \ | ||
| 678 | --exclude="*/.hermes/*.db-journal" \ | ||
| 679 | --exclude="*/gateway.pid" --exclude="*/cron.pid" --exclude="*/gateway.lock" \ | ||
| 680 | --exclude="*/processes.json" .' bash "$guest_tmp" | ||
| 681 | smolvm machine cp "$n:$guest_tmp" "$raw" | ||
| 682 | smolvm machine exec --name "$n" -- rm -f -- "$guest_tmp" | ||
| 683 | _untrack_guest_temp "$n" "$guest_tmp" | ||
| 684 | _validate_archive "$raw" || die "generated archive failed validation" | ||
| 685 | _resume_box "$n" | ||
| 686 | |||
| 687 | say "Encrypting and verifying backup" | ||
| 688 | gpg_enc "$encrypted" <"$raw" | ||
| 689 | gpg_dec "$encrypted" | tar tzf - >/dev/null || die "encrypted backup verification failed" | ||
| 690 | ln "$encrypted" "$out" 2>/dev/null || die "backup destination appeared during save: $out" | ||
| 691 | rm -f "$encrypted" | ||
| 692 | say "Saved and verified $(du -h "$out" | cut -f1) -> $out" | ||
| 693 | } | ||
| 694 | |||
| 695 | cmd_load() { | ||
| 696 | local file="${1:?usage: box load <archive.tar.gz.gpg> [name] [--keep-on-failure]}" n="${2:-restored}" raw guest_tmp | ||
| 697 | local keep_on_failure=0 log_file stage="" | ||
| 698 | [[ "${3:-}" != --keep-on-failure && "${BOX_KEEP_ON_FAILURE:-0}" != 1 ]] || keep_on_failure=1 | ||
| 699 | [[ -f "$file" ]] || die "no such archive: $file" | ||
| 700 | _validate_name "$n" | ||
| 701 | _host_preflight full | ||
| 702 | _acquire_lock "$n" | ||
| 703 | raw="$(mktemp)" | ||
| 704 | _track_temp "$raw" | ||
| 705 | say "Decrypting and validating archive before creating a VM" | ||
| 706 | gpg_dec "$file" >"$raw" | ||
| 707 | _validate_archive "$raw" || die "archive is invalid or unsafe" | ||
| 708 | |||
| 709 | say "Creating and provisioning fresh instance '$n'" | ||
| 710 | log_file="$(mktemp)" | ||
| 711 | _track_temp "$log_file" | ||
| 712 | if ! _create_base "$n"; then | ||
| 713 | stage="creating the base VM" | ||
| 714 | elif ! _provision_into "$n" full "$log_file"; then | ||
| 715 | stage="provisioning" | ||
| 716 | fi | ||
| 717 | if [[ -n "$stage" ]]; then | ||
| 718 | _save_failed_transcript "$n" "$log_file" | ||
| 719 | if [[ "$keep_on_failure" == 1 ]]; then | ||
| 720 | _clear_created "$n" | ||
| 721 | warn "leaving '$n' in place for inspection (--keep-on-failure / BOX_KEEP_ON_FAILURE=1)" | ||
| 722 | warn "remove when done with: ./box rm '$n' --force" | ||
| 723 | fi | ||
| 724 | die "box load failed while $stage" | ||
| 725 | fi | ||
| 726 | |||
| 727 | guest_tmp="/root/hb-restore-$(date +%Y%m%d-%H%M%S)-$$-${RANDOM}.tgz" | ||
| 728 | _track_guest_temp "$n" "$guest_tmp" | ||
| 729 | # shellcheck disable=SC2016 # expanded by the guest shell; archive is positional | ||
| 730 | if ! _pause_box "$n" 0 || ! smolvm machine cp "$raw" "$n:$guest_tmp" || | ||
| 731 | ! smolvm machine exec --name "$n" -- chmod 0600 "$guest_tmp" || | ||
| 732 | ! smolvm machine exec --name "$n" -- bash -c \ | ||
| 733 | 'set -euo pipefail | ||
| 734 | archive=$1 | ||
| 735 | trap '\''rm -f -- "$archive"'\'' EXIT HUP INT TERM | ||
| 736 | stage=/data/.hb-restore-stage-$$ | ||
| 737 | rollback=/data/.hb-restore-rollback-$$ | ||
| 738 | rm -rf "$stage" "$rollback" | ||
| 739 | mkdir -m 0700 "$stage" "$rollback" | ||
| 740 | tar xzf "$archive" --no-same-owner -C "$stage" | ||
| 741 | test -d "$stage/home/agent" | ||
| 742 | chown root:root "$stage" "$stage/home" | ||
| 743 | chmod 0711 "$stage" "$stage/home" | ||
| 744 | chown -R agent:agent "$stage/home/agent" "$stage/logs" 2>/dev/null || true | ||
| 745 | test "$(stat -c %u "$stage")" = 0 | ||
| 746 | test "$(stat -c %u "$stage/home")" = 0 | ||
| 747 | test "$(stat -c %a "$stage")" = 711 | ||
| 748 | test "$(stat -c %a "$stage/home")" = 711 | ||
| 749 | mkdir -p "$stage/home/agent/.config/hermes-box" | ||
| 750 | touch "$stage/home/agent/.config/hermes-box/quiesced" \ | ||
| 751 | "$stage/home/agent/.config/hermes-box/gateway-disabled" \ | ||
| 752 | "$stage/home/agent/.config/hermes-box/gateway-policy-initialized" | ||
| 753 | chown -R agent:agent "$stage/home/agent/.config/hermes-box" | ||
| 754 | setpriv --reuid=agent --regid=agent --init-groups test -x "$stage" | ||
| 755 | setpriv --reuid=agent --regid=agent --init-groups test ! -w "$stage" | ||
| 756 | setpriv --reuid=agent --regid=agent --init-groups test -x "$stage/home" | ||
| 757 | setpriv --reuid=agent --regid=agent --init-groups test ! -w "$stage/home" | ||
| 758 | setpriv --reuid=agent --regid=agent --init-groups test -w "$stage/home/agent" | ||
| 759 | setpriv --reuid=agent --regid=agent --init-groups env HOME=/data/home/agent \ | ||
| 760 | /opt/hermes-box/bin/hermes-state verify --home "$stage/home/agent/.hermes" >/dev/null | ||
| 761 | shopt -s dotglob nullglob | ||
| 762 | for item in /data/*; do | ||
| 763 | [[ "$item" == "$stage" || "$item" == "$rollback" ]] && continue | ||
| 764 | mv "$item" "$rollback/" | ||
| 765 | done | ||
| 766 | restore_failed=1 | ||
| 767 | trap '\''if [[ "$restore_failed" == 1 ]]; then find /data -mindepth 1 -maxdepth 1 ! -path "$rollback" -exec rm -rf -- {} +; mv "$rollback"/* /data/ 2>/dev/null || true; fi; rm -rf "$stage" "$rollback"; rm -f -- "$archive"'\'' EXIT HUP INT TERM | ||
| 768 | mv "$stage"/* /data/ | ||
| 769 | restore_failed=0' bash "$guest_tmp" || | ||
| 770 | ! _provision_into "$n" assets "$log_file" || ! _guest_hb "$n" gateway-disable || | ||
| 771 | ! _guest_hb "$n" verify-state >/dev/null || ! _resume_box "$n" || ! _prepare_runtime "$n"; then | ||
| 772 | _save_failed_transcript "$n" "$log_file" | ||
| 773 | if [[ "$keep_on_failure" == 1 ]]; then | ||
| 774 | _clear_created "$n" | ||
| 775 | warn "leaving '$n' in place for inspection (--keep-on-failure / BOX_KEEP_ON_FAILURE=1)" | ||
| 776 | warn "remove when done with: ./box rm '$n' --force" | ||
| 777 | die "restore failed; left incomplete destination '$n' for inspection" | ||
| 778 | fi | ||
| 779 | die "restore failed; attempting cleanup of the incomplete destination" | ||
| 780 | fi | ||
| 781 | _untrack_guest_temp "$n" "$guest_tmp" | ||
| 782 | _clear_created "$n" | ||
| 783 | say "Restored and verified. Enter with: ./box enter $n" | ||
| 784 | } | ||
| 785 | |||
| 786 | cmd_import_hermes() { | ||
| 787 | local n="${1:?usage: box import-hermes <name> <hermes-backup.zip> [--external NAME]... [--map OLD=NEW]...}" | ||
| 788 | local file="${2:?usage: box import-hermes <name> <hermes-backup.zip> [--external NAME]... [--map OLD=NEW]...}" | ||
| 789 | local guest_tmp map external already_paused=0 was_running=0 validation external_count archive_external planned_skips | ||
| 790 | shift 2 | ||
| 791 | _validate_name "$n" | ||
| 792 | [[ -f "$file" ]] || die "no such Hermes backup: $file" | ||
| 793 | _host_preflight import | ||
| 794 | # Keep a blank sentinel because macOS Bash 3.2 treats an expanded empty array | ||
| 795 | # as unbound under `set -u`. | ||
| 796 | local maps=() externals=("") | ||
| 797 | [[ -z "${HERMES_IMPORT_PATH_MAP:-}" ]] || maps+=("$HERMES_IMPORT_PATH_MAP") | ||
| 798 | while (($#)); do | ||
| 799 | case "$1" in | ||
| 800 | --external) | ||
| 801 | [[ $# -ge 2 ]] || die "expected --external NAME" | ||
| 802 | external="$2" | ||
| 803 | [[ "$external" =~ ^[A-Za-z0-9._-]+$ && "$external" != . && "$external" != .. ]] || | ||
| 804 | die "invalid external provider name '$external'" | ||
| 805 | case "$external" in | ||
| 806 | .ssh | .config | workspace | .bashrc | .bash_profile | .profile | .zshrc | .zprofile | .zshenv) | ||
| 807 | die "reserved external destination '$external' is never importable" ;; | ||
| 808 | esac | ||
| 809 | externals+=("$external") | ||
| 810 | shift 2 | ||
| 811 | ;; | ||
| 812 | --map) | ||
| 813 | [[ $# -ge 2 ]] || die "expected --map OLD=NEW" | ||
| 814 | map="$2" | ||
| 815 | [[ "$map" == *=* && -n "${map%%=*}" && -n "${map#*=}" ]] || die "invalid path map '$map'" | ||
| 816 | maps+=("$map") | ||
| 817 | shift 2 | ||
| 818 | ;; | ||
| 819 | *) die "expected --external NAME or --map OLD=NEW" ;; | ||
| 820 | esac | ||
| 821 | done | ||
| 822 | validation="$(python3 "$ROOT/guest/hermes-state" validate-zip "$file")" || die "Hermes backup is invalid or unsafe" | ||
| 823 | external_count="$(jq '.external_top_level_entries | length' <<<"$validation")" | ||
| 824 | planned_skips="$(jq '.migration_scope.skipped_files' <<<"$validation")" | ||
| 825 | if [[ "$planned_skips" != 0 ]]; then | ||
| 826 | warn "Native import will intentionally skip ${planned_skips} non-portable or transient file(s):" | ||
| 827 | jq -r '.migration_scope.skipped_by_reason | to_entries[] | " \(.key): \(.value)"' <<<"$validation" >&2 | ||
| 828 | fi | ||
| 829 | if [[ "$external_count" != 0 ]]; then | ||
| 830 | say "External provider destinations in archive:" | ||
| 831 | jq -r '.external_top_level_entries[] | " /data/home/agent/" + .' <<<"$validation" | ||
| 832 | fi | ||
| 833 | while IFS= read -r archive_external; do | ||
| 834 | [[ -n "$archive_external" ]] || continue | ||
| 835 | case "$archive_external" in | ||
| 836 | .ssh | .config | workspace | .bashrc | .bash_profile | .profile | .zshrc | .zprofile | .zshenv) | ||
| 837 | die "archive requests reserved external destination '$archive_external'" ;; | ||
| 838 | esac | ||
| 839 | local approved=0 | ||
| 840 | for external in "${externals[@]}"; do [[ "$external" == "$archive_external" ]] && approved=1; done | ||
| 841 | [[ "$approved" == 1 ]] || die "external provider '$archive_external' is unapproved; re-run with --external '$archive_external'" | ||
| 842 | done < <(jq -r '.external_top_level_entries[]' <<<"$validation") | ||
| 843 | for external in "${externals[@]}"; do | ||
| 844 | [[ -n "$external" ]] || continue | ||
| 845 | jq -e --arg name "$external" '.external_top_level_entries | index($name) != null' <<<"$validation" >/dev/null || | ||
| 846 | die "approved external provider '$external' is not present in the archive" | ||
| 847 | done | ||
| 848 | _acquire_lock "$n" | ||
| 849 | _machine_exists "$n" || die "smolvm machine '$n' is missing" | ||
| 850 | _machine_running "$n" && was_running=1 | ||
| 851 | [[ "$was_running" == 1 ]] || _track_stop_after "$n" | ||
| 852 | smolvm machine start --name "$n" >/dev/null | ||
| 853 | _guest_hb "$n" is-paused >/dev/null 2>&1 && already_paused=1 | ||
| 854 | _guest_hb "$n" gateway-disable >/dev/null | ||
| 855 | _pause_box "$n" | ||
| 856 | guest_tmp="/data/home/agent/.config/hermes-box/import-$(date +%Y%m%d-%H%M%S)-$$-${RANDOM}.zip" | ||
| 857 | _track_guest_temp "$n" "$guest_tmp" | ||
| 858 | smolvm machine exec --name "$n" -- \ | ||
| 859 | install -d -o agent -g agent -m 0700 /data/home/agent/.config/hermes-box | ||
| 860 | smolvm machine cp "$file" "$n:$guest_tmp" | ||
| 861 | smolvm machine exec --name "$n" -- chown agent:agent "$guest_tmp" | ||
| 862 | smolvm machine exec --name "$n" -- chmod 0600 "$guest_tmp" | ||
| 863 | local import_args=(import-zip "$guest_tmp") | ||
| 864 | for external in "${externals[@]}"; do | ||
| 865 | [[ -n "$external" ]] && import_args+=(--external "$external") | ||
| 866 | done | ||
| 867 | for map in "${maps[@]}"; do import_args+=(--map "$map"); done | ||
| 868 | say "Importing validated Hermes state transactionally into '$n'" | ||
| 869 | smolvm machine exec --name "$n" -- \ | ||
| 870 | setpriv --reuid=agent --regid=agent --init-groups \ | ||
| 871 | env HOME=/data/home/agent HERMES_HOME=/data/home/agent/.hermes \ | ||
| 872 | /opt/hermes-box/bin/hermes-state "${import_args[@]}" >/dev/null | ||
| 873 | smolvm machine exec --name "$n" -- rm -f -- "$guest_tmp" | ||
| 874 | _untrack_guest_temp "$n" "$guest_tmp" | ||
| 875 | _guest_hb "$n" verify-state >/dev/null | ||
| 876 | if [[ "$already_paused" == 0 ]]; then _resume_box "$n"; fi | ||
| 877 | if [[ "$was_running" == 0 ]]; then | ||
| 878 | smolvm machine stop --name "$n" | ||
| 879 | _clear_stop_after "$n" | ||
| 880 | fi | ||
| 881 | say "Hermes import verified. Gateway remains disabled; intentional exclusions are recorded in the import manifest. Inspect active paths, acknowledge them, then run the cutover gates." | ||
| 882 | } | ||
| 883 | |||
| 884 | cmd_extract() { | ||
| 885 | local file="${1:?usage: box extract <archive.tar.gz.gpg> [dest-dir]}" base dest raw | ||
| 886 | [[ -f "$file" ]] || die "no such archive: $file" | ||
| 887 | _host_preflight archive | ||
| 888 | base="$(basename "$file")" | ||
| 889 | base="${base%.tar.gz.gpg}" | ||
| 890 | dest="${2:-backups/${base}-extracted}" | ||
| 891 | [[ ! -e "$dest" ]] || die "$dest already exists" | ||
| 892 | raw="$(mktemp)" | ||
| 893 | _track_temp "$raw" | ||
| 894 | gpg_dec "$file" >"$raw" | ||
| 895 | _validate_archive "$raw" || die "archive is invalid or unsafe" | ||
| 896 | mkdir -p "$dest" | ||
| 897 | if ! tar xzf "$raw" --no-same-owner -C "$dest"; then | ||
| 898 | rm -rf "$dest" | ||
| 899 | die "extraction failed" | ||
| 900 | fi | ||
| 901 | say "Extracted $(find "$dest" -type f | wc -l | tr -d ' ') files -> $dest/" | ||
| 902 | } | ||
| 903 | |||
| 904 | cmd_ls() { | ||
| 905 | _host_preflight basic | ||
| 906 | if [[ "${1:-}" == --all ]]; then smolvm machine ls; return; fi | ||
| 907 | [[ -s "$REG" ]] || { echo "No hermes-box-lite boxes yet. Create one: ./box new <name>"; return; } | ||
| 908 | printf '%-16s %-10s %-32s %s\n' BOX STATE EXECUTOR HERMES_API | ||
| 909 | local n port api_port _api_guest st url api_url | ||
| 910 | while IFS=$'\t' read -r n port api_port _api_guest; do | ||
| 911 | [[ -n "$n" ]] || continue | ||
| 912 | if st="$(smolvm machine status --name "$n" 2>/dev/null)"; then | ||
| 913 | st="${st#*: }" | ||
| 914 | st="${st%% *}" | ||
| 915 | else | ||
| 916 | st=missing | ||
| 917 | fi | ||
| 918 | url='—' | ||
| 919 | api_url='—' | ||
| 920 | [[ -n "$port" ]] && url="http://localhost:${port}" | ||
| 921 | [[ -n "$api_port" ]] && api_url="http://localhost:${api_port}" | ||
| 922 | printf '%-16s %-10s %-32s %s\n' "$n" "$st" "$url" "$api_url" | ||
| 923 | done <"$REG" | ||
| 924 | } | ||
| 925 | |||
| 926 | cmd_open() { | ||
| 927 | local n="${1:?usage: box open <name>}" port token url | ||
| 928 | _validate_name "$n" | ||
| 929 | _host_preflight health | ||
| 930 | _acquire_lock "$n" | ||
| 931 | port="$(_reg_port "$n")" | ||
| 932 | [[ -n "$port" ]] || die "'$n' has no exposed Executor host port" | ||
| 933 | _machine_running "$n" || die "box '$n' is not running; run './box start $n' before opening Executor" | ||
| 934 | _guest_hb "$n" is-paused >/dev/null 2>&1 && | ||
| 935 | die "box '$n' is quiesced; run './box start $n' and 'hb resume' explicitly before opening Executor" | ||
| 936 | _guest_hb "$n" up >/dev/null | ||
| 937 | token="$(_guest_hb "$n" executor-token)" || die "could not read Executor's local token" | ||
| 938 | [[ -n "$token" ]] || die "Executor token is unavailable" | ||
| 939 | _wait_mcp_ready "http://127.0.0.1:${port}/mcp" "$token" || | ||
| 940 | die "Executor did not become ready on the forwarded host endpoint" | ||
| 941 | url="http://localhost:${port}/?_token=${token}" | ||
| 942 | say "Opening authenticated Executor UI at http://localhost:${port}" | ||
| 943 | _open_url "$url" || | ||
| 944 | die "could not launch a browser; set BOX_BROWSER to a graphical opener (the authenticated URL is passed directly and never printed)" | ||
| 945 | } | ||
| 946 | |||
| 947 | _open_url() { | ||
| 948 | local url="$1" platform | ||
| 949 | if [[ -n "${BOX_BROWSER:-}" ]]; then | ||
| 950 | command -v "$BOX_BROWSER" >/dev/null 2>&1 || return 1 | ||
| 951 | "$BOX_BROWSER" "$url" >/dev/null 2>&1 | ||
| 952 | return | ||
| 953 | fi | ||
| 954 | platform="$(uname -s)" | ||
| 955 | if [[ "$platform" == Darwin ]] && command -v open >/dev/null 2>&1; then | ||
| 956 | open "$url" >/dev/null 2>&1 | ||
| 957 | return | ||
| 958 | elif [[ "$platform" == Linux ]]; then | ||
| 959 | if command -v xdg-open >/dev/null 2>&1 && xdg-open "$url" >/dev/null 2>&1; then return; fi | ||
| 960 | if command -v gio >/dev/null 2>&1 && gio open "$url" >/dev/null 2>&1; then return; fi | ||
| 961 | fi | ||
| 962 | return 1 | ||
| 963 | } | ||
| 964 | |||
| 965 | cmd_doctor() { | ||
| 966 | local n="${1:?usage: box doctor <name>}" port api_port token guest_quiesced=0 gateway_disabled=0 | ||
| 967 | _validate_name "$n" | ||
| 968 | _host_preflight basic | ||
| 969 | _acquire_lock "$n" | ||
| 970 | port="$(_reg_port "$n")" | ||
| 971 | api_port="$(_reg_api_port "$n")" | ||
| 972 | _machine_exists "$n" || die "smolvm machine '$n' is missing" | ||
| 973 | _guest_hb "$n" is-paused >/dev/null 2>&1 && guest_quiesced=1 | ||
| 974 | if [[ -n "$api_port" && "$guest_quiesced" == 0 ]]; then | ||
| 975 | _guest_hb "$n" gateway-is-disabled >/dev/null 2>&1 && gateway_disabled=1 | ||
| 976 | fi | ||
| 977 | _guest_hb "$n" doctor || die "guest readiness or health check failed for '$n' (doctor never starts or resumes a box)" | ||
| 978 | if [[ -n "$port" && "$guest_quiesced" == 0 ]]; then | ||
| 979 | _host_preflight health | ||
| 980 | token="$(_guest_hb "$n" executor-token)" || die "could not read Executor's local token" | ||
| 981 | [[ -n "$token" ]] || die "Executor token is unavailable" | ||
| 982 | _wait_mcp_ready "http://127.0.0.1:${port}/mcp" "$token" || | ||
| 983 | die "host Executor MCP health check failed on port $port" | ||
| 984 | say "host Executor MCP: healthy on http://localhost:${port}" | ||
| 985 | elif [[ "$guest_quiesced" == 1 ]]; then | ||
| 986 | say "host Executor MCP: intentionally quiesced" | ||
| 987 | else | ||
| 988 | say "host Executor MCP: skipped (no exposed host port)" | ||
| 989 | fi | ||
| 990 | if [[ -n "$api_port" && "$guest_quiesced" == 0 && "$gateway_disabled" == 0 ]]; then | ||
| 991 | _host_preflight health | ||
| 992 | _hermes_api_healthy "$api_port" || die "host Hermes API health failed on port $api_port (expected 2xx Hermes JSON)" | ||
| 993 | say "host Hermes API: healthy on http://localhost:${api_port}" | ||
| 994 | elif [[ "$guest_quiesced" == 1 ]]; then | ||
| 995 | say "host Hermes API: intentionally quiesced" | ||
| 996 | elif [[ "$gateway_disabled" == 1 ]]; then | ||
| 997 | say "host Hermes API: intentionally disabled by gateway policy" | ||
| 998 | else | ||
| 999 | say "host Hermes API: skipped (disabled)" | ||
| 1000 | fi | ||
| 1001 | say "doctor passed for '$n'" | ||
| 1002 | } | ||
| 1003 | |||
| 1004 | cmd_repair() { | ||
| 1005 | local n="${1:?usage: box repair <name>}" already_paused=0 | ||
| 1006 | _validate_name "$n" | ||
| 1007 | _host_preflight provision | ||
| 1008 | _acquire_lock "$n" | ||
| 1009 | _machine_exists "$n" || die "smolvm machine '$n' is missing" | ||
| 1010 | smolvm machine start --name "$n" >/dev/null | ||
| 1011 | _guest_hb "$n" is-paused >/dev/null 2>&1 && already_paused=1 | ||
| 1012 | _pause_box "$n" | ||
| 1013 | _provision_into "$n" full | ||
| 1014 | if [[ "$already_paused" == 1 ]]; then | ||
| 1015 | _guest_hb "$n" doctor | ||
| 1016 | say "repair completed for '$n'; durable quiesce preserved" | ||
| 1017 | else | ||
| 1018 | _resume_box "$n" | ||
| 1019 | _prepare_runtime "$n" | ||
| 1020 | say "repair completed for '$n'" | ||
| 1021 | fi | ||
| 1022 | } | ||
| 1023 | |||
| 1024 | cmd_stop() { | ||
| 1025 | local n="${1:?usage: box stop <name>}" | ||
| 1026 | _validate_name "$n" | ||
| 1027 | _host_preflight basic | ||
| 1028 | _acquire_lock "$n" | ||
| 1029 | smolvm machine stop --name "$n" | ||
| 1030 | } | ||
| 1031 | |||
| 1032 | cmd_start() { | ||
| 1033 | local n="${1:?usage: box start <name>}" | ||
| 1034 | _validate_name "$n" | ||
| 1035 | _host_preflight basic | ||
| 1036 | _acquire_lock "$n" | ||
| 1037 | smolvm machine start --name "$n" | ||
| 1038 | } | ||
| 1039 | |||
| 1040 | cmd_rm() { | ||
| 1041 | local n="${1:?usage: box rm <name> [--force]}" force="${2:-}" answer latest | ||
| 1042 | _validate_name "$n" | ||
| 1043 | _host_preflight basic | ||
| 1044 | latest="$(find backups -maxdepth 1 -type f -name "${n}-*.tar.gz.gpg" -print 2>/dev/null | sort | tail -1)" | ||
| 1045 | if [[ -n "$latest" ]]; then say "latest backup: $latest"; else warn "no backup found for '$n'"; fi | ||
| 1046 | if [[ "$force" != --force ]]; then | ||
| 1047 | [[ -r /dev/tty ]] || die "refusing non-interactive deletion without --force" | ||
| 1048 | printf "Type the box name '%s' to delete it permanently: " "$n" >/dev/tty | ||
| 1049 | read -r answer </dev/tty | ||
| 1050 | [[ "$answer" == "$n" ]] || die "deletion cancelled" | ||
| 1051 | fi | ||
| 1052 | _acquire_lock "$n" | ||
| 1053 | smolvm machine delete --name "$n" -f | ||
| 1054 | _acquire_lock registry | ||
| 1055 | _reg_del "$n" | ||
| 1056 | _release_lock registry | ||
| 1057 | say "deleted '$n'" | ||
| 1058 | } | ||
| 1059 | |||
| 1060 | usage() { | ||
| 1061 | cat <<'EOF' | ||
| 1062 | hermes-box-lite — isolated, portable agent boxes (claude / codex / hermes / executor) | ||
| 1063 | |||
| 1064 | ./box new <name> [--keep-on-failure] | ||
| 1065 | Create, provision, and health-check a fresh box | ||
| 1066 | ./box enter <name> Log in as agent and attach tmux 'main' | ||
| 1067 | ./box ls [--all] List managed boxes (or every smolvm VM) | ||
| 1068 | ./box open <name> Open the authenticated Executor web UI | ||
| 1069 | ./box doctor <name> Read-only health check; never starts or resumes a box | ||
| 1070 | ./box repair <name> Re-run provisioning and repair managed guest assets | ||
| 1071 | ./box stop|start <name> Stop or start an instance | ||
| 1072 | ./box save <name> [out] Quiesce, validate, encrypt, and verify /data | ||
| 1073 | ./box load <file> [name] [--keep-on-failure] | ||
| 1074 | Validate first, then build and restore a fresh box | ||
| 1075 | ./box import-hermes <name> <hermes-backup.zip> [--external NAME]... [--map OLD=NEW]... | ||
| 1076 | Transactionally import native Hermes state; gateway stays disabled | ||
| 1077 | ./box extract <file> [dir] Validate and extract a backup without a VM | ||
| 1078 | ./box rm <name> [--force] Delete after confirmation (or explicit --force) | ||
| 1079 | |||
| 1080 | Set BOX_PASSPHRASE to skip the GPG prompt. Configuration: box.env | ||
| 1081 | Set BOX_KEEP_ON_FAILURE=1 (or pass --keep-on-failure to `new`/`load`) to | ||
| 1082 | skip rollback on a failed `new`/`load` and leave the VM up for inspection. | ||
| 1083 | A failed `new`/`load` also saves its provisioning transcript under | ||
| 1084 | backups/.failed/ before any rollback runs. | ||
| 1085 | EOF | ||
| 1086 | } | ||
| 1087 | |||
| 1088 | main() { | ||
| 1089 | _install_cleanup_traps | ||
| 1090 | case "${1:-help}" in | ||
| 1091 | new) shift; cmd_new "$@" ;; | ||
| 1092 | enter | shell) shift; cmd_enter "$@" ;; | ||
| 1093 | ls) shift; cmd_ls "$@" ;; | ||
| 1094 | open) shift; cmd_open "$@" ;; | ||
| 1095 | doctor) shift; cmd_doctor "$@" ;; | ||
| 1096 | repair | provision) shift; cmd_repair "$@" ;; | ||
| 1097 | stop) shift; cmd_stop "$@" ;; | ||
| 1098 | start) shift; cmd_start "$@" ;; | ||
| 1099 | save) shift; cmd_save "$@" ;; | ||
| 1100 | load) shift; cmd_load "$@" ;; | ||
| 1101 | import-hermes) shift; cmd_import_hermes "$@" ;; | ||
| 1102 | extract) shift; cmd_extract "$@" ;; | ||
| 1103 | rm) shift; cmd_rm "$@" ;; | ||
| 1104 | help | -h | --help) usage ;; | ||
| 1105 | *) usage; return 1 ;; | ||
| 1106 | esac | ||
| 1107 | } | ||
| 1108 | |||
| 1109 | if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@"; fi | ||
| @@ -0,0 +1,43 @@ | |||
| 1 | # hermes-box-lite on Docker — everything baked into one image at build time. | ||
| 2 | # | ||
| 3 | # The whole provisioning story (node, uv, claude, codex, hermes, executor, | ||
| 4 | # guest assets, /data skeleton) is the existing provision/provision.sh, run | ||
| 5 | # once at BUILD time instead of streamed into a live VM on every `box new`. | ||
| 6 | # Creating a box becomes `docker run` + named volume: seconds, not minutes, | ||
| 7 | # and every box is bit-identical to the image it came from. | ||
| 8 | # | ||
| 9 | # /data is declared a VOLUME after the skeleton is seeded, so a fresh named | ||
| 10 | # volume is auto-populated from the image on first mount (Docker copies the | ||
| 11 | # image's mount-point contents into an empty named volume). | ||
| 12 | # | ||
| 13 | # Build from the repository root: | ||
| 14 | # docker build -t hermes-box-lite -f docker/Dockerfile . | ||
| 15 | |||
| 16 | FROM ubuntu:24.04 | ||
| 17 | |||
| 18 | SHELL ["/bin/bash", "-o", "pipefail", "-c"] | ||
| 19 | |||
| 20 | # Two provisioning layers so day-to-day guest-script edits don't re-run the | ||
| 21 | # multi-minute tool installs: `tools` (node/uv/claude/codex/hermes/executor) | ||
| 22 | # caches until box.env or provision.sh changes; `assets` (hb, profiles, /data | ||
| 23 | # skeleton) rebuilds in seconds when guest/ changes. | ||
| 24 | COPY box.env /tmp/ctx/box.env | ||
| 25 | COPY provision/ /tmp/ctx/provision/ | ||
| 26 | RUN bash /tmp/ctx/provision/provision.sh tools | ||
| 27 | |||
| 28 | COPY guest/ /tmp/ctx/guest/ | ||
| 29 | RUN bash /tmp/ctx/provision/provision.sh assets && rm -rf /tmp/ctx | ||
| 30 | |||
| 31 | COPY docker/entrypoint.sh /opt/hermes-box/bin/entrypoint | ||
| 32 | COPY docker/executor-entrypoint.sh /opt/hermes-box/bin/executor-entrypoint | ||
| 33 | RUN chmod 0755 /opt/hermes-box/bin/entrypoint /opt/hermes-box/bin/executor-entrypoint | ||
| 34 | |||
| 35 | # Durable state. Everything else in the container is disposable image content. | ||
| 36 | VOLUME /data | ||
| 37 | |||
| 38 | # Fixed internal bridge ports (hb-workload's socat bridges bind 0.0.0.0 here | ||
| 39 | # and forward to the loopback-only services). Hosts publish these with -p and | ||
| 40 | # let Docker pick free host ports — no port registry, no collision logic. | ||
| 41 | EXPOSE 14788 18642 | ||
| 42 | |||
| 43 | ENTRYPOINT ["/opt/hermes-box/bin/entrypoint"] | ||
| @@ -0,0 +1,25 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Container PID-1 workload (run under docker --init so signals behave). | ||
| 3 | # Reuses guest/hb-workload verbatim: it reconciles Executor, the Hermes | ||
| 4 | # gateway, and the 0.0.0.0 socat bridges every 20s. The outer loop is the | ||
| 5 | # supervisor for the loop process itself; Docker's restart policy is the | ||
| 6 | # supervisor for the container. | ||
| 7 | set -uo pipefail | ||
| 8 | |||
| 9 | trap 'exit 143' TERM | ||
| 10 | trap 'exit 130' INT | ||
| 11 | |||
| 12 | # Idempotent: ensures the /data skeleton and gateway-policy markers exist | ||
| 13 | # even when the volume predates this image (e.g. restored from a backup). | ||
| 14 | # Fail fast if it can't — a container without a sane /data must not look | ||
| 15 | # healthy, so the restart policy (not a silent loop) handles recovery. | ||
| 16 | /opt/hermes-box/bin/hb init || exit 1 | ||
| 17 | |||
| 18 | EXEC_BRIDGE_PORT="${BOX_EXECUTOR_BRIDGE_PORT:-14788}" | ||
| 19 | API_BRIDGE_PORT="${BOX_HERMES_BRIDGE_PORT:-18642}" | ||
| 20 | |||
| 21 | while true; do | ||
| 22 | runuser -u agent -- env HOME=/data/home/agent \ | ||
| 23 | /opt/hermes-box/bin/hb-workload "$EXEC_BRIDGE_PORT" "$API_BRIDGE_PORT" || true | ||
| 24 | sleep 2 | ||
| 25 | done | ||
| @@ -0,0 +1,21 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # PID-1 for the executor service container. Runs ONLY the Executor daemon, | ||
| 3 | # foreground, as the agent user, bound to 0.0.0.0 so both the private compose | ||
| 4 | # network (the agent container) and the published host port (LAN/Tailscale | ||
| 5 | # dashboard) can reach it. The bearer token is the gate on every request. | ||
| 6 | # | ||
| 7 | # This container is the trust boundary: the hermes/agent container never | ||
| 8 | # shares a namespace, filesystem, or process table with it — its only surface | ||
| 9 | # is this HTTP endpoint. | ||
| 10 | set -euo pipefail | ||
| 11 | |||
| 12 | [[ -n "${EXECUTOR_MCP_TOKEN:-}" ]] || { echo "EXECUTOR_MCP_TOKEN is required" >&2; exit 1; } | ||
| 13 | |||
| 14 | mkdir -p /data/home/agent /data/logs | ||
| 15 | chown agent:agent /data/home/agent /data/logs | ||
| 16 | |||
| 17 | # shellcheck disable=SC2016 # $EXECUTOR_MCP_TOKEN expands in the inner shell | ||
| 18 | exec runuser -u agent -- env HOME=/data/home/agent \ | ||
| 19 | bash --noprofile --norc -c '. /etc/profile.d/hermes-box.sh | ||
| 20 | exec executor daemon run --foreground --hostname 0.0.0.0 --port 4788 \ | ||
| 21 | --auth-token "$EXECUTOR_MCP_TOKEN"' | ||
| @@ -0,0 +1,231 @@ | |||
| 1 | # Proposed architecture: hermes-box-lite on Docker | ||
| 2 | |||
| 3 | Status: architecture record, 2026-07-03. The `boxd` bash prototype referenced | ||
| 4 | throughout was verified against this design and then **deleted** (2026-07-04) | ||
| 5 | in favor of the Go CLI specified in [tx9-cli-design.md](tx9-cli-design.md); | ||
| 6 | recover it from git history (`6f1ced3` and earlier) when porting logic. The | ||
| 7 | runtime topology, contracts, and verification results below remain the | ||
| 8 | authoritative description of what the Go CLI must create. | ||
| 9 | |||
| 10 | ## Why move off smolvm | ||
| 11 | |||
| 12 | smolvm gives real VM isolation, but in practice it has been the least | ||
| 13 | reliable part of the system: bespoke lifecycle semantics, the 4 GiB | ||
| 14 | pack-export cap that forces overlay-slimming gymnastics, provisioning that | ||
| 15 | streams a tarball into a live VM on **every** `box new` (minutes, network | ||
| 16 | required, can fail halfway and needs rollback machinery), and `machine exec` | ||
| 17 | quirks that shaped a lot of defensive code in `./box`. | ||
| 18 | |||
| 19 | Docker trades the VM boundary for a mature, boring runtime: | ||
| 20 | |||
| 21 | - **Build once, run many.** Provisioning happens once at `docker build`; | ||
| 22 | `boxd new` is a `docker run` and completes in seconds. Failed builds leave | ||
| 23 | nothing behind, so `_rollback_created`, keep-on-failure, failed-transcript | ||
| 24 | saving, and the create/provision/prepare staging in `cmd_new` all disappear. | ||
| 25 | - **The daemon is the registry.** Containers and volumes are labelled | ||
| 26 | `hermes-box-lite=1`; `docker ps`/`volume ls` replace `.boxes`, and the | ||
| 27 | Docker daemon's own serialization replaces `.box-locks`. | ||
| 28 | - **Ports without bookkeeping.** Each container exposes two fixed internal | ||
| 29 | bridge ports; Docker publishes them to free host ports. No `_assign_port`, | ||
| 30 | no port registry columns, no collision scanning. | ||
| 31 | |||
| 32 | ## Model | ||
| 33 | |||
| 34 | One box = one Docker Compose project (`hbl-<name>`) with **two containers on | ||
| 35 | a private network**. This split exists because the main security question is | ||
| 36 | Executor: the agent (Hermes, claude, codex) should be free to do whatever it | ||
| 37 | wants in its own container, and Executor — the thing that holds real | ||
| 38 | credentials and takes real actions — should not share a filesystem, process | ||
| 39 | table, or user namespace with it. | ||
| 40 | |||
| 41 | ```text | ||
| 42 | ./docker/boxd build (once per box.env / pin change) | ||
| 43 | │ provision.sh runs INSIDE `docker build` (tools layer + assets layer) | ||
| 44 | ▼ | ||
| 45 | image hermes-box-lite:latest one image, two entrypoints | ||
| 46 | │ | ||
| 47 | ./docker/boxd new alpha (seconds) | ||
| 48 | ▼ | ||
| 49 | compose project hbl-alpha ──────────────────────────────────────────┐ | ||
| 50 | │ │ | ||
| 51 | │ ┌────────────────────────────┐ ┌───────────────────────────┐ │ | ||
| 52 | │ │ agent ("linux land") │ │ executor │ │ | ||
| 53 | │ │ claude · codex · hermes │ │ Executor daemon ONLY │ │ | ||
| 54 | │ │ gateway; free rein, sudo │ │ (foreground, 0.0.0.0:4788,│ │ | ||
| 55 | │ │ │ │ fixed bearer token) │ │ | ||
| 56 | │ │ volume: agent-data → /data │ │ volume: exec-data → /data │ │ | ||
| 57 | │ │ THE box; save/load target │ │ executor scratch state │ │ | ||
| 58 | │ │ NO published ports │ │ publishes 4788 → host │ │ | ||
| 59 | │ └─────────────┬──────────────┘ └─────┬──────────────┬──────┘ │ | ||
| 60 | │ │ http://executor:4788 │ │ │ | ||
| 61 | │ └──── private network ─────┘ │ │ | ||
| 62 | └──────────────────────────── (per-project; no cross-box) ─┼────────┘ | ||
| 63 | ▼ | ||
| 64 | host 0.0.0.0:<auto> — LAN + Tailscale | ||
| 65 | dashboard at / · MCP at /mcp | ||
| 66 | bearer token gates every request | ||
| 67 | │ | ||
| 68 | ./docker/boxd save alpha (archives agent /data only) | ||
| 69 | ▼ | ||
| 70 | backups/alpha-<ts>.tar.gz.gpg — SAME format as ./box save; archives are | ||
| 71 | interchangeable between the two runtimes | ||
| 72 | ``` | ||
| 73 | |||
| 74 | Containers are disposable; the `agent-data` volume is the box. The executor | ||
| 75 | container's volume is scratch — its identity is the bearer token boxd mints | ||
| 76 | per box into `.boxd/<name>.env` (mode 0600) and injects into both containers. | ||
| 77 | `hb` inside the agent wires claude/codex MCP against `http://executor:4788/mcp` | ||
| 78 | with that token (`EXECUTOR_HOST` + `BOXD_EXECUTOR_TOKEN`); the same token | ||
| 79 | authenticates you to the dashboard/MCP from the LAN or Tailscale. | ||
| 80 | |||
| 81 | What the agent container can do to Executor: exactly what any LAN client can | ||
| 82 | do — talk HTTP to the token-gated endpoint. Nothing else. Cross-box: each | ||
| 83 | compose project gets its own network, so `alpha`'s agent cannot even resolve | ||
| 84 | `beta`'s executor (verified: DNS fails). | ||
| 85 | |||
| 86 | ### What stays exactly the same | ||
| 87 | |||
| 88 | - `provision/provision.sh` — unchanged logic, now two cached build layers | ||
| 89 | (`tools` for the multi-minute installs, `assets` for repo-owned guest | ||
| 90 | files, so guest-script edits rebuild in seconds). | ||
| 91 | - `guest/` (hb, hb-workload, hermes-state, profiles, tmux) — hb gained a | ||
| 92 | remote-executor mode (`EXECUTOR_HOST` ≠ loopback → check reachability and | ||
| 93 | wire MCP remotely instead of spawning/stopping a local daemon); everything | ||
| 94 | else unchanged. | ||
| 95 | - `/data` layout, quiesce/gateway-policy markers, the single-writer gateway | ||
| 96 | gates, and the encrypted backup format + `hermes-state verify`. | ||
| 97 | - `box.env` as the single tuning file (now mostly consumed at build time). | ||
| 98 | |||
| 99 | ### What is deleted | ||
| 100 | |||
| 101 | | smolvm machinery | Docker replacement | | ||
| 102 | | --- | --- | | ||
| 103 | | `.boxes` registry + mkdir-lock forest + stale-lock recovery | labels + one flock per box | | ||
| 104 | | `_assign_port`, port ranges, registry columns | `-p 0.0.0.0::<internal>` auto-assign | | ||
| 105 | | per-`new` provisioning stream, rollback, keep-on-failure, failed transcripts | `docker build` atomicity | | ||
| 106 | | overlay 4 GiB cap workarounds (`slim`, ffmpeg preseed rationale) | image layers, no pack-export cap | | ||
| 107 | | `smolvm machine cp` staging + guest temp tracking | `docker exec` stdout / stdin streaming | | ||
| 108 | | outer while-true supervisor inside the VM spec | `--restart unless-stopped` + `--init` | | ||
| 109 | |||
| 110 | Roughly: `./box` is ~1100 lines; `boxd` is ~300, and most of the deleted 800 | ||
| 111 | lines were compensation for smolvm's sharp edges. | ||
| 112 | |||
| 113 | ## The trade: isolation | ||
| 114 | |||
| 115 | Decision (2026-07-03): VM isolation is a nice-to-have; containers are enough | ||
| 116 | for now, and the agent keeps passwordless sudo inside its container. The | ||
| 117 | security question that actually matters is **Executor**, and the compose | ||
| 118 | split answers it structurally rather than with hardening flags: | ||
| 119 | |||
| 120 | - The agent container is deliberately permissive — that's linux land, the | ||
| 121 | Hermes agent runs around freely, sudo included. What it can reach is the | ||
| 122 | network, its own volume, and one token-gated HTTP endpoint. | ||
| 123 | - Executor's credentials and execution surface live in a container the agent | ||
| 124 | cannot exec into, whose filesystem it cannot see, whose processes it cannot | ||
| 125 | signal. Compromising the agent gets you the same position as any | ||
| 126 | unauthenticated LAN client: an HTTP endpoint that 401s without the token. | ||
| 127 | (The agent does hold a valid token — that's its job — so "agent can call | ||
| 128 | Executor tools" is by design; "agent can tamper with Executor itself" | ||
| 129 | is what the split removes.) | ||
| 130 | - Per-project networks mean boxes can't see each other at all. | ||
| 131 | |||
| 132 | gVisor (`--runtime=runsc`) remains the one-flag escape hatch if VM-grade | ||
| 133 | isolation is ever wanted back — the design doesn't change. | ||
| 134 | |||
| 135 | Networking stance is unchanged from smolvm (always-on, tools need internet). | ||
| 136 | The executor's single published port binds 0.0.0.0 (LAN + Tailscale, per | ||
| 137 | decision); the agent container publishes nothing. | ||
| 138 | |||
| 139 | ## Lifecycle mapping | ||
| 140 | |||
| 141 | | smolvm (`./box`) | Docker (`boxd`) | Notes | | ||
| 142 | | --- | --- | --- | | ||
| 143 | | `new` (5–10 min) | `build` once + `new` (seconds) | compose up: agent + executor + network | | ||
| 144 | | `enter` | `enter` | exec into the **agent** container → same tmux attach | | ||
| 145 | | `ls` | `ls` | agent state + executor dashboard URL per box | | ||
| 146 | | `doctor` | `doctor` | in-box `hb doctor` (remote-executor aware) + host dashboard probe | | ||
| 147 | | `save` / `load` | `save` / `load` | same archive format; archives agent /data only; restored boxes arrive quiesced + gateway-disabled with a **fresh executor token** | | ||
| 148 | | `repair` | `build` + `compose up` again | volumes untouched; "repair" becomes "replace" | | ||
| 149 | | `import-hermes` | port later | pure `hermes-state` work; runtime-agnostic | | ||
| 150 | | `stop`/`start`/`rm` | same | both containers together; `rm` deletes containers **and** volumes after typed confirmation | | ||
| 151 | |||
| 152 | The "repair becomes replace" row is the philosophical center: containers are | ||
| 153 | cattle, volumes are pets. Any doubt about a box's runtime → delete the | ||
| 154 | container, `boxd new`-style recreate against the same volume. There is no | ||
| 155 | drift to repair because managed bits are image layers. | ||
| 156 | |||
| 157 | ## Dry-run results (2026-07-03, on nexus-class hardware, Docker 29) | ||
| 158 | |||
| 159 | - `boxd build`: one-time, ~10 min (dominated by the Hermes installer), 5.97 GB | ||
| 160 | image. No 4 GiB cap to fight; `slim()` still runs but is now optional. | ||
| 161 | - `boxd new dry1`: **3.4 seconds** to a fully doctor-green box (vs minutes on | ||
| 162 | smolvm), including executor up + MCP wiring + full `hb doctor`. | ||
| 163 | - `save` → `load` roundtrip: 27 MB encrypted archive; restored box came up | ||
| 164 | quiesced with gateway disabled, workspace files intact, `/data` permission | ||
| 165 | topology (root 711 / root 711 / agent) preserved, `hb resume` + full doctor | ||
| 166 | green afterwards. | ||
| 167 | - `stop`/`start` and `docker restart` preserve state; the reconcile loop | ||
| 168 | revives executor after restart without intervention. | ||
| 169 | - Codex-driven behavioral QA (8 error-path scenarios: duplicate name, invalid | ||
| 170 | name, missing box, missing archive, wrong passphrase, nonexistent rm, | ||
| 171 | concurrent saves against the per-box lock): all pass, no orphaned | ||
| 172 | containers/volumes after any failure path. | ||
| 173 | - In-box tool verification: claude 2.1.201, codex 0.142.5, hermes, executor | ||
| 174 | 1.5.28, node 22, uv, nvim, tmux, passwordless sudo, and the /data-pinned | ||
| 175 | CLAUDE_CONFIG_DIR/CODEX_HOME/HERMES_HOME env all check out. One cosmetic | ||
| 176 | wart: codex prints "could not create PATH aliases: Operation not permitted" | ||
| 177 | because the npm global prefix is a root-owned image layer — harmless | ||
| 178 | (aliases already exist from build time), but worth silencing later. | ||
| 179 | - One real Docker-specific gotcha found and fixed: executor's daemon binds | ||
| 180 | `localhost`, which in a default Docker container resolves to `::1` first — | ||
| 181 | daemon listened on IPv6 loopback while every health check probed | ||
| 182 | `127.0.0.1`. Fixed by running containers with | ||
| 183 | `--sysctl net.ipv6.conf.all.disable_ipv6=1` (matches the smolvm guest, | ||
| 184 | which had no routable IPv6 either). | ||
| 185 | |||
| 186 | ### Compose-split verification (same day, after the two-container rework) | ||
| 187 | |||
| 188 | - `boxd new` on the compose template: agent + executor + private network up, | ||
| 189 | MCP wired against `http://executor:4788/mcp`, full doctor green first try. | ||
| 190 | - Isolation properties verified live: agent container publishes **zero** | ||
| 191 | ports; MCP returns 401 without the bearer token and 200 with it (from the | ||
| 192 | host and from inside the agent); dashboard serves on the published port; | ||
| 193 | `dry1`'s agent cannot resolve `dry3`'s executor (per-project networks). | ||
| 194 | - save/load across the split: restored box gets a **fresh token**, and | ||
| 195 | `hb wire-once` detects the stale restored wiring (old token + old URL in | ||
| 196 | the archive) and rewires — this was a real bug found in the dry run: the | ||
| 197 | profile sources the restored `executor-mcp.env`, which clobbered the | ||
| 198 | injected token env var. Fixed with a `BOXD_EXECUTOR_TOKEN` override that | ||
| 199 | outranks the on-disk file, plus a URL-freshness check in `wire_once`. | ||
| 200 | - The Dockerfile now provisions in two layers (`tools` then `assets`), so | ||
| 201 | guest-script iteration rebuilds the image in seconds instead of ~10 min. | ||
| 202 | |||
| 203 | ## Draft gaps (known, deliberate for a first pass) | ||
| 204 | |||
| 205 | - `boxd doctor` only runs the in-box doctor; host-side MCP/API health probes | ||
| 206 | from `./box doctor` aren't ported yet. | ||
| 207 | - `import-hermes` and `extract` aren't ported (both are runtime-agnostic and | ||
| 208 | move over mechanically). | ||
| 209 | - `boxd save` resumes via a single EXIT-trap slot; the multi-box | ||
| 210 | pause-tracking arrays from `./box` aren't needed since one boxd invocation | ||
| 211 | touches one box. | ||
| 212 | - Restored boxes re-run `hb init` via the entrypoint, which is what re-owns | ||
| 213 | the skeleton, but a `boxd load` doesn't run the `assets` provisioning | ||
| 214 | refresh — with Docker it doesn't need to: managed assets are image layers | ||
| 215 | and can't have been clobbered by the archive (the archive only touches | ||
| 216 | /data). | ||
| 217 | - Restored boxes start quiesced with the gateway disabled (correct), but the | ||
| 218 | `boxd load` UX for walking the cutover gates is just a printed hint. | ||
| 219 | - No `boxd repair` — intentionally; see above. | ||
| 220 | |||
| 221 | ## Suggested migration | ||
| 222 | |||
| 223 | 1. Land `docker/` alongside the smolvm path (no removals), mark experimental. | ||
| 224 | 2. Run a throwaway box (`boxd new scratch`) for day-to-day agent work for a | ||
| 225 | week; the state that matters is exercised by real use. | ||
| 226 | 3. Migrate a real box: `./box save alpha` on smolvm → `boxd load` the same | ||
| 227 | archive → walk the gateway gates. The shared archive format makes this a | ||
| 228 | one-command migration each way, and smolvm remains the rollback. | ||
| 229 | 4. When confident: port `import-hermes`/`extract`/full archive validation, | ||
| 230 | move `tests/` fixtures from a fake `smolvm` to a fake `docker`, then | ||
| 231 | retire the smolvm path. | ||
| @@ -0,0 +1,126 @@ | |||
| 1 | # tx9 CLI — design | ||
| 2 | |||
| 3 | Status: agreed 2026-07-04 (12 decisions below, settled in conversation with | ||
| 4 | Davis). Nothing here is implemented yet; this document is the spec the Go | ||
| 5 | implementation is built from. The bash prototypes that informed it (`./box` | ||
| 6 | on smolvm, `./docker/boxd` on compose) are deleted; recover them from git | ||
| 7 | history (`9350212` and earlier) when porting logic. | ||
| 8 | |||
| 9 | ## What tx9 is | ||
| 10 | |||
| 11 | A single Go binary installed on a machine ("tx9") that creates and manages | ||
| 12 | hermes boxes — the two-container agent/executor pairs described in | ||
| 13 | [docker-architecture.md](docker-architecture.md). No repo checkout, no | ||
| 14 | `./docker/boxd`, no compose plugin. The CLI is the product; the repo is its | ||
| 15 | source. | ||
| 16 | |||
| 17 | ``` | ||
| 18 | curl -fsSL https://tx9.davis7.sh/install | bash | ||
| 19 | tx9 create | ||
| 20 | ``` | ||
| 21 | |||
| 22 | ## The 12 decisions | ||
| 23 | |||
| 24 | | # | Question | Decision | | ||
| 25 | |---|---|---| | ||
| 26 | | 1 | Image source | **Embedded assets, local build.** The binary `go:embed`s provision/, guest/, Dockerfile, box.env; first `create` builds the image locally (~10 min, then cached). No registry. | | ||
| 27 | | 2 | State home | **`~/.tx9/`** — `boxes/<name>.env` (token cache), `locks/`, `config.toml`. Docker objects are label-tagged so `tx9 list` reconstructs truth from the daemon even if `~/.tx9` is lost. Backups default to **`~/Downloads`**. | | ||
| 28 | | 3 | Naming + create UX | Friendly generated names (`large-cat` style) by default, `tx9 create my_box` for explicit. `create` ends with a printed numbered checklist (enter, auth claude/codex, hermes setup, dashboard URL). | | ||
| 29 | | 4 | Command surface | See table below. `enter`/`ssh` are aliases; `backup`/`export` are aliases. | | ||
| 30 | | 5 | Password UX | Precedence: `--password` flag → `TX9_PASSWORD` env → interactive hidden prompt. **`--no-encrypt` escape hatch** for quick local snapshots. | | ||
| 31 | | 6 | Archive format | **`.tx9` extension** (tar.gz inside, GPG-wrapped unless --no-encrypt; CLI sniffs which). Embeds box-name metadata; `import` restores under that name, `--name` overrides, collision = hard fail. | | ||
| 32 | | 7 | Scope | **Local-only v1.** The CLI drives the local Docker daemon; cross-machine moves are backup → transfer yourself → import. (Remote is a saved future direction — see below.) | | ||
| 33 | | 8 | Docker interface | **Engine API via the official Go SDK**, not shelling to compose. tx9 creates the network/volumes/containers itself. Kills the compose-plugin dependency (bit us on siva) and owns all progress/error UX. | | ||
| 34 | | 9 | Repo | **This repo.** Go code lives alongside provision/ and guest/ because they co-evolve; embedding pulls from source at build time. | | ||
| 35 | | 10 | Distribution | **GitHub Releases** (cross-compiled, tag-push CI) + **`https://tx9.davis7.sh/install`** (redirect to the raw installer; detects OS/arch, drops binary in `~/.local/bin`). Repo goes public when ready; hosted page/site later. | | ||
| 36 | | 11 | Versioning | `tx9 upgrade` (no args) self-updates the CLI; `tx9 upgrade <box>` moves a box onto the current image. Images tagged `tx9-box:<cli-version>`; `list` shows per-box image version so drift is visible; unused old images pruned. | | ||
| 37 | | 12 | Bash prototypes | **Both deleted** (done, this commit). git history is the reference. | | ||
| 38 | |||
| 39 | ## Command surface | ||
| 40 | |||
| 41 | | Command | Behavior | | ||
| 42 | |---|---| | ||
| 43 | | `tx9 create [name]` | Generate name if absent. Build `tx9-box:<version>` if missing (with real progress UX). Create network + volumes + both containers, mint token, wire MCP, run doctor. Print getting-started checklist. | | ||
| 44 | | `tx9 list` | All boxes on this machine from daemon labels: state (running/stopped/crashed), image version vs CLI version (drift flag), dashboard URL. | | ||
| 45 | | `tx9 enter <box>` (alias `ssh`) | Exec into the agent container as `agent`, tmux `main` attach. Starts the box if stopped. | | ||
| 46 | | `tx9 start <box>` / `tx9 stop <box>` | Both containers together. Volumes persist. | | ||
| 47 | | `tx9 backup <box>` (alias `export`) | Flags: `--path` (default `~/Downloads`), `--password`/env/prompt, `--no-encrypt`. Quiesce → archive agent /data → validate → (encrypt) → verify → `<box>-<timestamp>.tx9`. | | ||
| 48 | | `tx9 import <file.tx9>` | Flags: `--name`, `--password`/env/prompt. Validate before creating anything; restore staged; arrive quiesced + gateway-disabled + fresh token; fail on name collision. | | ||
| 49 | | `tx9 open <box>` | Print (or open) the authenticated dashboard URL (`?_token=`). | | ||
| 50 | | `tx9 doctor <box>` | In-box `hb doctor` + host-side published-port probe. | | ||
| 51 | | `tx9 upgrade [box]` | No args: self-update (re-run installer logic). With box: recreate containers on current image + readiness gate. | | ||
| 52 | | `tx9 delete <box>` | Containers + volumes + token file, typed-name confirmation (`--force` to skip). | | ||
| 53 | | `tx9 prune` | Remove unused `tx9-box:*` image versions and stale state files. | | ||
| 54 | |||
| 55 | ## Fixed contracts (carried from the verified bash draft) | ||
| 56 | |||
| 57 | These were dry-run- and QA-verified in the boxd prototype and must survive | ||
| 58 | the port: | ||
| 59 | |||
| 60 | - **Topology**: agent container (no published ports, volume = the box, sudo, | ||
| 61 | IPv6 disabled via sysctl) + executor container (daemon only, | ||
| 62 | `--hostname 0.0.0.0 --port 4788 --auth-token <token>`, one published | ||
| 63 | 0.0.0.0 host port, scratch volume) + per-box private network. Labels | ||
| 64 | (`tx9=1`, `tx9.box=<name>`, `tx9.version=<v>`) on every object. | ||
| 65 | - **Token**: 256-bit random per box, minted at create/import, injected as | ||
| 66 | `BOXD_EXECUTOR_TOKEN` (outranks any restored on-disk `executor-mcp.env` — | ||
| 67 | real bug found in dry run) and `EXECUTOR_HOST=executor`. `hb wire-once` | ||
| 68 | re-wires when token *or URL* is stale. | ||
| 69 | - **Archive safety**: the member-by-member validator (absolute paths, | ||
| 70 | traversal, special files, duplicates, link targets/cycles/nesting) ported | ||
| 71 | from bash+python to Go. Validate before create on import; validate after | ||
| 72 | tar on backup. Restores stage-then-promote in a throwaway container and | ||
| 73 | re-arm quiesce + gateway-disabled markers. | ||
| 74 | - **Gateway single-writer**: restored boxes never auto-enable the Hermes | ||
| 75 | gateway. `hb gateway-enable --confirm-single-writer …` stays the only path. | ||
| 76 | - **Entrypoints**: agent PID-1 = `hb init` (fail fast) + hb-workload loop | ||
| 77 | under docker `--init`; executor PID-1 = foreground daemon. Restart policy | ||
| 78 | `unless-stopped`. | ||
| 79 | - **Gotchas to preserve**: containers need | ||
| 80 | `net.ipv6.conf.all.disable_ipv6=1` (executor binds `localhost` → `::1` | ||
| 81 | otherwise); right after start the executor needs a boot-window retry | ||
| 82 | before health checks pass. | ||
| 83 | |||
| 84 | ## Go implementation notes | ||
| 85 | |||
| 86 | - `go:embed` a tar of `provision/`, `guest/`, `docker/Dockerfile`, | ||
| 87 | entrypoints, `box.env` → feed to the Engine API's ImageBuild as the build | ||
| 88 | context. Image tag from the CLI's ldflags version. | ||
| 89 | - Name generator: small embedded adjective+animal word lists. | ||
| 90 | - Per-box flock in `~/.tx9/locks/` (same semantics as boxd: one operation | ||
| 91 | per box; fail fast, don't queue). | ||
| 92 | - `create` progress: stream ImageBuild output through a spinner/step UI on | ||
| 93 | first build; subsequent creates are seconds. | ||
| 94 | - `.tx9` format: tar.gz of `/data` plus a small JSON metadata header | ||
| 95 | (box name, created-at, CLI/image version, encrypted flag). Simplest | ||
| 96 | encoding: outer tar with `metadata.json` + `data.tar.gz[.gpg]` members — | ||
| 97 | sniffable, streamable, and the metadata stays readable without the | ||
| 98 | passphrase. | ||
| 99 | - Encryption: keep GPG-compatible AES-256 symmetric (shell out to gpg, or | ||
| 100 | use a Go OpenPGP lib) so operators can decrypt archives without tx9 in an | ||
| 101 | emergency. | ||
| 102 | - Doctor/MCP checks: port `lib-mcp.sh`'s initialize handshake to Go (proper | ||
| 103 | HTTP client, jq logic in code). | ||
| 104 | |||
| 105 | ## Saved for later (explicitly out of v1) | ||
| 106 | |||
| 107 | - **Remote/multi-host** — worth doing eventually: `tx9 --host nexus list`, | ||
| 108 | or `tx9 move large-cat nexus` (backup → stream → import → gateway gates, | ||
| 109 | one command from a laptop). Design constraint to keep in mind now: keep | ||
| 110 | the daemon interface behind a small interface type so a remote Docker | ||
| 111 | daemon (SSH tunnel / `DOCKER_HOST`) slots in without restructuring. | ||
| 112 | - Hosted docs/site at tx9.davis7.sh beyond the install redirect. | ||
| 113 | - Registry-published images (decision 1 fallback if local builds annoy). | ||
| 114 | - Compose healthchecks / `depends_on: service_healthy` equivalents via the | ||
| 115 | SDK health config. | ||
| 116 | - Executor-state backup (currently: scratch by contract, does not travel). | ||
| 117 | - Slim executor image variant; token rotation command; executor egress | ||
| 118 | policy network. | ||
| 119 | |||
| 120 | ## Migration to tx9 | ||
| 121 | |||
| 122 | The guest layer (`guest/`, `provision/`, `box.env`) is shared and already | ||
| 123 | compose-split aware, so existing boxd-created boxes are structurally | ||
| 124 | identical to what tx9 will create — only labels/names differ | ||
| 125 | (`hbl-*` vs `tx9-*`). Don't build compatibility: the running dry boxes are | ||
| 126 | disposable, and any box that matters moves via `backup`/`import` anyway. | ||
| @@ -0,0 +1,38 @@ | |||
| 1 | module github.com/davis7dotsh/tx9 | ||
| 2 | |||
| 3 | go 1.26 | ||
| 4 | |||
| 5 | require ( | ||
| 6 | github.com/docker/docker v28.5.2+incompatible | ||
| 7 | github.com/docker/go-connections v0.7.0 | ||
| 8 | golang.org/x/sys v0.46.0 | ||
| 9 | ) | ||
| 10 | |||
| 11 | require ( | ||
| 12 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect | ||
| 13 | github.com/Microsoft/go-winio v0.6.2 // indirect | ||
| 14 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | ||
| 15 | github.com/containerd/errdefs v1.0.0 // indirect | ||
| 16 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | ||
| 17 | github.com/containerd/log v0.1.0 // indirect | ||
| 18 | github.com/distribution/reference v0.6.0 // indirect | ||
| 19 | github.com/docker/go-units v0.5.0 // indirect | ||
| 20 | github.com/felixge/httpsnoop v1.0.4 // indirect | ||
| 21 | github.com/go-logr/logr v1.4.3 // indirect | ||
| 22 | github.com/go-logr/stdr v1.2.2 // indirect | ||
| 23 | github.com/moby/docker-image-spec v1.3.1 // indirect | ||
| 24 | github.com/moby/sys/atomicwriter v0.1.0 // indirect | ||
| 25 | github.com/moby/term v0.5.2 // indirect | ||
| 26 | github.com/morikuni/aec v1.1.0 // indirect | ||
| 27 | github.com/opencontainers/go-digest v1.0.0 // indirect | ||
| 28 | github.com/opencontainers/image-spec v1.1.1 // indirect | ||
| 29 | github.com/pkg/errors v0.9.1 // indirect | ||
| 30 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect | ||
| 31 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect | ||
| 32 | go.opentelemetry.io/otel v1.44.0 // indirect | ||
| 33 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect | ||
| 34 | go.opentelemetry.io/otel/metric v1.44.0 // indirect | ||
| 35 | go.opentelemetry.io/otel/trace v1.44.0 // indirect | ||
| 36 | golang.org/x/time v0.15.0 // indirect | ||
| 37 | gotest.tools/v3 v3.5.2 // indirect | ||
| 38 | ) | ||
| @@ -0,0 +1,112 @@ | |||
| 1 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= | ||
| 2 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= | ||
| 3 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= | ||
| 4 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= | ||
| 5 | github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= | ||
| 6 | github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= | ||
| 7 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= | ||
| 8 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | ||
| 9 | github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= | ||
| 10 | github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= | ||
| 11 | github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= | ||
| 12 | github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= | ||
| 13 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= | ||
| 14 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= | ||
| 15 | github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= | ||
| 16 | github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= | ||
| 17 | github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= | ||
| 18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||
| 19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
| 20 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= | ||
| 21 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= | ||
| 22 | github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= | ||
| 23 | github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= | ||
| 24 | github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= | ||
| 25 | github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= | ||
| 26 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= | ||
| 27 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= | ||
| 28 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= | ||
| 29 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= | ||
| 30 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= | ||
| 31 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= | ||
| 32 | github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | ||
| 33 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= | ||
| 34 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= | ||
| 35 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= | ||
| 36 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | ||
| 37 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | ||
| 38 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||
| 39 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||
| 40 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= | ||
| 41 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= | ||
| 42 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | ||
| 43 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | ||
| 44 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= | ||
| 45 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= | ||
| 46 | github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= | ||
| 47 | github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= | ||
| 48 | github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= | ||
| 49 | github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= | ||
| 50 | github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= | ||
| 51 | github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= | ||
| 52 | github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= | ||
| 53 | github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= | ||
| 54 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= | ||
| 55 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= | ||
| 56 | github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= | ||
| 57 | github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= | ||
| 58 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | ||
| 59 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | ||
| 60 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||
| 61 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||
| 62 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= | ||
| 63 | github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= | ||
| 64 | github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= | ||
| 65 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= | ||
| 66 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= | ||
| 67 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | ||
| 68 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | ||
| 69 | go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= | ||
| 70 | go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= | ||
| 71 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= | ||
| 72 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= | ||
| 73 | go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= | ||
| 74 | go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= | ||
| 75 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= | ||
| 76 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= | ||
| 77 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= | ||
| 78 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= | ||
| 79 | go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= | ||
| 80 | go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= | ||
| 81 | go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= | ||
| 82 | go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= | ||
| 83 | go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= | ||
| 84 | go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= | ||
| 85 | go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= | ||
| 86 | go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= | ||
| 87 | go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= | ||
| 88 | go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= | ||
| 89 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= | ||
| 90 | golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= | ||
| 91 | golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= | ||
| 92 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| 93 | golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= | ||
| 94 | golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | ||
| 95 | golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= | ||
| 96 | golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= | ||
| 97 | golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= | ||
| 98 | golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= | ||
| 99 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= | ||
| 100 | google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= | ||
| 101 | google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= | ||
| 102 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= | ||
| 103 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= | ||
| 104 | google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= | ||
| 105 | google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= | ||
| 106 | google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= | ||
| 107 | google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= | ||
| 108 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | ||
| 109 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||
| 110 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||
| 111 | gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= | ||
| 112 | gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= | ||
| @@ -22,6 +22,15 @@ WIRED="$AGENT_HOME/.hermes-box-wired" · 21 unmodified lines | |||
| 22 | TOKEN_FILE="$AGENT_HOME/.executor/server-control/auth.json" | 22 | TOKEN_FILE="$AGENT_HOME/.executor/server-control/auth.json" |
| 23 | TOKEN_ENV="$AGENT_HOME/.config/hermes-box/executor-mcp.env" | 23 | TOKEN_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. | ||
| 30 | EXECUTOR_HOST="${EXECUTOR_HOST:-127.0.0.1}" | ||
| 31 | _exec_remote() { [[ "$EXECUTOR_HOST" != 127.0.0.1 ]]; } | ||
| 32 | _executor_url() { printf 'http://%s:%s/mcp' "$EXECUTOR_HOST" "${EXECUTOR_PORT:-4788}"; } | ||
| 33 | |||
| 25 | init() { | 34 | init() { |
| 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 1 | 57 | 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 | init | 130 | 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 || true | 205 | command -v executor >/dev/null 2>&1 && executor daemon stop >/dev/null 2>&1 || true |
| 189 | pkill -f "executor daemon run" 2>/dev/null || true | 206 | pkill -f "executor daemon run" 2>/dev/null || true |
| 190 | rm -f "$AGENT_HOME/.executor"/daemon-active-* 2>/dev/null || true | 207 | rm -f "$AGENT_HOME/.executor"/daemon-active-* 2>/dev/null || true |
| @@ -302,8 +319,18 @@ web() { · 111 unmodified lines | |||
| 302 | } | 319 | } |
| 303 | 320 | ||
| 304 | executor_token() { | 321 | executor_token() { |
| 305 | [[ -r "$TOKEN_FILE" ]] || return 1 | 322 | # BOXD_EXECUTOR_TOKEN is the runtime-injected token in the compose split. |
| 306 | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TOKEN_FILE" | head -1 | 323 | # It must win over EXECUTOR_MCP_TOKEN because /etc/profile.d sources the |
| 324 | # on-disk executor-mcp.env, and after a restore that file carries the | ||
| 325 | # SOURCE box's stale token — clobbering EXECUTOR_MCP_TOKEN and quietly | ||
| 326 | # wiring MCP against a dead credential. | ||
| 327 | if [[ -n "${BOXD_EXECUTOR_TOKEN:-}" ]]; then printf '%s\n' "$BOXD_EXECUTOR_TOKEN"; return 0; fi | ||
| 328 | if [[ -r "$TOKEN_FILE" ]]; then | ||
| 329 | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TOKEN_FILE" | head -1 | ||
| 330 | return 0 | ||
| 331 | fi | ||
| 332 | [[ -n "${EXECUTOR_MCP_TOKEN:-}" ]] && { printf '%s\n' "$EXECUTOR_MCP_TOKEN"; return 0; } | ||
| 333 | return 1 | ||
| 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 0 | 363 | return 0 |
| 337 | fi | 364 | 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/null | 366 | up >/dev/null |
| 340 | local token url="http://127.0.0.1:${EXECUTOR_PORT:-4788}/mcp" failed=0 | 367 | local token url failed=0 |
| 368 | url="$(_executor_url)" | ||
| 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 | ||
| 364 | wire_once() { | 392 | wire_once() { |
| 365 | local token | 393 | local token url |
| 366 | if [[ "${WIRE_EXECUTOR_MCP:-0}" != 1 ]]; then wire_mcp; return; fi | 394 | 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; fi | 401 | grep -Fq "$token" "$TOKEN_ENV" && |
| 402 | grep -qF "url = \"$url\"" "$CODEX_HOME/config.toml" 2>/dev/null; then return 0; fi | ||
| 370 | wire_mcp | 403 | 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 token | 472 | local token |
| 440 | token="$(executor_token)" || return 1 | 473 | 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 | ||
| 444 | doctor() { | 477 | doctor() { |
| @@ -453,7 +486,12 @@ doctor() { · 8 unmodified lines | |||
| 453 | else | 486 | else |
| 454 | printf 'ok Hermes gateway intentionally disabled or quiesced\n' | 487 | printf 'ok Hermes gateway intentionally disabled or quiesced\n' |
| 455 | fi | 488 | fi |
| 456 | if [[ "${INSTALL_EXECUTOR:-0}" == 1 ]]; then | 489 | if _exec_remote; then |
| 490 | # Remote executor keeps running while this box quiesces; only reachability | ||
| 491 | # and auth are this box's concern. | ||
| 492 | _check "executor reachable (remote $EXECUTOR_HOST)" _port_open || failed=1 | ||
| 493 | _check "executor MCP bearer accepted" _mcp_auth_works || failed=1 | ||
| 494 | elif [[ "${INSTALL_EXECUTOR:-0}" == 1 ]]; then | ||
| 457 | _check "executor installed" _command_exists executor || failed=1 | 495 | _check "executor installed" _command_exists executor || failed=1 |
| 458 | if [[ -e "$QUIESCE_FILE" ]]; then | 496 | if [[ -e "$QUIESCE_FILE" ]]; then |
| 459 | _check "Executor target port closed while quiesced" _port_closed || failed=1 | 497 | _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=1 | 511 | _check "MCP wiring version" test "$(cat "$WIRED" 2>/dev/null)" = "$WIRE_VERSION" || failed=1 |
| 474 | _check "MCP token environment" test -r "$TOKEN_ENV" || failed=1 | 512 | _check "MCP token environment" test -r "$TOKEN_ENV" || failed=1 |
| 475 | _check "Claude HTTP MCP config" _claude_http_config || failed=1 | 513 | _check "Claude HTTP MCP config" _claude_http_config || failed=1 |
| 476 | _check "Codex HTTP MCP config" grep -q "url = \"http://127.0.0.1:${EXECUTOR_PORT:-4788}/mcp\"" "$CODEX_HOME/config.toml" || failed=1 | 514 | _check "Codex HTTP MCP config" grep -qF "url = \"$(_executor_url)\"" "$CODEX_HOME/config.toml" || failed=1 |
| 477 | fi | 515 | fi |
| 478 | [[ "$failed" == 0 ]] | 516 | [[ "$failed" == 0 ]] |
| 479 | } | 517 | } |
| @@ -0,0 +1,201 @@ | |||
| 1 | // Package archive implements the .tx9 backup container format: a small, | ||
| 2 | // pure-Go, docker-free library used by `tx9 backup`/`tx9 import` | ||
| 3 | // (internal/cli) for building, reading, and validating archives. Nothing | ||
| 4 | // here talks to Docker — callers hand it files/streams and get files/ | ||
| 5 | // streams back, so it's independently testable and reusable if a future | ||
| 6 | // remote-move feature (docs/tx9-cli-design.md "saved for later") needs the | ||
| 7 | // same format without a live daemon. | ||
| 8 | // | ||
| 9 | // Container shape (docs/tx9-cli-design.md decision 6): a `.tx9` file is an | ||
| 10 | // uncompressed outer tar with exactly two members, in order: | ||
| 11 | // | ||
| 12 | // 1. metadata.json — always present, always plaintext, so `import` can | ||
| 13 | // read the target box name and whether a password prompt is needed | ||
| 14 | // without touching the (possibly encrypted) payload. | ||
| 15 | // 2. data.tar.gz or data.tar.gz.gpg — the actual box data, gzip-compressed | ||
| 16 | // (and optionally GPG-symmetric-encrypted on top). The outer tar is | ||
| 17 | // deliberately uncompressed: this member is already compressed (and | ||
| 18 | // possibly encrypted, i.e. incompressible), so wrapping it in another | ||
| 19 | // compression pass would only burn CPU. | ||
| 20 | package archive | ||
| 21 | |||
| 22 | import ( | ||
| 23 | "archive/tar" | ||
| 24 | "encoding/json" | ||
| 25 | "fmt" | ||
| 26 | "io" | ||
| 27 | "os" | ||
| 28 | "time" | ||
| 29 | ) | ||
| 30 | |||
| 31 | // FormatVersion is the current .tx9 container format version, written into | ||
| 32 | // every archive's metadata.json and checked on read so a future incompatible | ||
| 33 | // format change fails loudly instead of silently misparsing. | ||
| 34 | const FormatVersion = 1 | ||
| 35 | |||
| 36 | // Tar member names inside a .tx9 container, in fixed order. | ||
| 37 | const ( | ||
| 38 | metadataMember = "metadata.json" | ||
| 39 | dataMemberPlain = "data.tar.gz" | ||
| 40 | dataMemberEncrypt = "data.tar.gz.gpg" | ||
| 41 | ) | ||
| 42 | |||
| 43 | // Metadata is the JSON header every .tx9 archive carries as its first tar | ||
| 44 | // member. | ||
| 45 | type Metadata struct { | ||
| 46 | BoxName string `json:"box_name"` | ||
| 47 | CreatedAt time.Time `json:"created_at"` | ||
| 48 | CLIVersion string `json:"cli_version"` | ||
| 49 | ImageVersion string `json:"image_version"` | ||
| 50 | Encrypted bool `json:"encrypted"` | ||
| 51 | FormatVersion int `json:"format_version"` | ||
| 52 | } | ||
| 53 | |||
| 54 | // dataMemberName returns the name the data member must have for a given | ||
| 55 | // Encrypted flag. | ||
| 56 | func dataMemberName(encrypted bool) string { | ||
| 57 | if encrypted { | ||
| 58 | return dataMemberEncrypt | ||
| 59 | } | ||
| 60 | return dataMemberPlain | ||
| 61 | } | ||
| 62 | |||
| 63 | // WriteTx9 writes a complete .tx9 container to w: metadata.json first, then | ||
| 64 | // the verbatim contents of dataFile as the second member (named | ||
| 65 | // data.tar.gz or data.tar.gz.gpg per meta.Encrypted). meta.FormatVersion is | ||
| 66 | // overwritten with the current FormatVersion regardless of what the caller | ||
| 67 | // set. | ||
| 68 | func WriteTx9(w io.Writer, meta Metadata, dataFile string) error { | ||
| 69 | meta.FormatVersion = FormatVersion | ||
| 70 | |||
| 71 | metaBytes, err := json.MarshalIndent(meta, "", " ") | ||
| 72 | if err != nil { | ||
| 73 | return fmt.Errorf("archive: marshal metadata: %w", err) | ||
| 74 | } | ||
| 75 | |||
| 76 | df, err := os.Open(dataFile) | ||
| 77 | if err != nil { | ||
| 78 | return fmt.Errorf("archive: open data file %s: %w", dataFile, err) | ||
| 79 | } | ||
| 80 | defer df.Close() | ||
| 81 | info, err := df.Stat() | ||
| 82 | if err != nil { | ||
| 83 | return fmt.Errorf("archive: stat data file %s: %w", dataFile, err) | ||
| 84 | } | ||
| 85 | |||
| 86 | now := time.Now() | ||
| 87 | tw := tar.NewWriter(w) | ||
| 88 | |||
| 89 | if err := tw.WriteHeader(&tar.Header{ | ||
| 90 | Typeflag: tar.TypeReg, | ||
| 91 | Name: metadataMember, | ||
| 92 | Mode: 0600, | ||
| 93 | Size: int64(len(metaBytes)), | ||
| 94 | ModTime: now, | ||
| 95 | }); err != nil { | ||
| 96 | return fmt.Errorf("archive: write metadata header: %w", err) | ||
| 97 | } | ||
| 98 | if _, err := tw.Write(metaBytes); err != nil { | ||
| 99 | return fmt.Errorf("archive: write metadata: %w", err) | ||
| 100 | } | ||
| 101 | |||
| 102 | if err := tw.WriteHeader(&tar.Header{ | ||
| 103 | Typeflag: tar.TypeReg, | ||
| 104 | Name: dataMemberName(meta.Encrypted), | ||
| 105 | Mode: 0600, | ||
| 106 | Size: info.Size(), | ||
| 107 | ModTime: info.ModTime(), | ||
| 108 | }); err != nil { | ||
| 109 | return fmt.Errorf("archive: write data header: %w", err) | ||
| 110 | } | ||
| 111 | if _, err := io.Copy(tw, df); err != nil { | ||
| 112 | return fmt.Errorf("archive: write data: %w", err) | ||
| 113 | } | ||
| 114 | |||
| 115 | if err := tw.Close(); err != nil { | ||
| 116 | return fmt.Errorf("archive: close container: %w", err) | ||
| 117 | } | ||
| 118 | return nil | ||
| 119 | } | ||
| 120 | |||
| 121 | // readMetadataMember reads and decodes the first tar member of path, | ||
| 122 | // erroring clearly if path doesn't look like a .tx9 container. It returns | ||
| 123 | // the open file positioned right after the metadata member (so | ||
| 124 | // ExtractData can continue reading the data member from the same reader) | ||
| 125 | // along with the decoded metadata. | ||
| 126 | func readMetadataMember(f *os.File) (Metadata, *tar.Reader, error) { | ||
| 127 | tr := tar.NewReader(f) | ||
| 128 | hdr, err := tr.Next() | ||
| 129 | if err != nil { | ||
| 130 | return Metadata{}, nil, fmt.Errorf("does not look like a .tx9 file (reading first member): %w", err) | ||
| 131 | } | ||
| 132 | if hdr.Name != metadataMember { | ||
| 133 | return Metadata{}, nil, fmt.Errorf("does not look like a .tx9 file (expected first member %q, got %q)", metadataMember, hdr.Name) | ||
| 134 | } | ||
| 135 | |||
| 136 | var meta Metadata | ||
| 137 | if err := json.NewDecoder(tr).Decode(&meta); err != nil { | ||
| 138 | return Metadata{}, nil, fmt.Errorf("invalid metadata.json: %w", err) | ||
| 139 | } | ||
| 140 | if meta.FormatVersion == 0 { | ||
| 141 | return Metadata{}, nil, fmt.Errorf("invalid metadata.json: missing format_version") | ||
| 142 | } | ||
| 143 | if meta.FormatVersion > FormatVersion { | ||
| 144 | return Metadata{}, nil, fmt.Errorf("archive format version %d is newer than this tx9 (supports up to %d) — upgrade tx9 first", meta.FormatVersion, FormatVersion) | ||
| 145 | } | ||
| 146 | return meta, tr, nil | ||
| 147 | } | ||
| 148 | |||
| 149 | // ReadMetadata reads only the first member of the .tx9 file at path and | ||
| 150 | // decodes it as Metadata, never touching the (possibly encrypted) second | ||
| 151 | // member. This is what lets `import` discover the target box name and | ||
| 152 | // whether a password prompt is needed before doing any real work. | ||
| 153 | func ReadMetadata(path string) (Metadata, error) { | ||
| 154 | f, err := os.Open(path) | ||
| 155 | if err != nil { | ||
| 156 | return Metadata{}, fmt.Errorf("archive: open %s: %w", path, err) | ||
| 157 | } | ||
| 158 | defer f.Close() | ||
| 159 | |||
| 160 | meta, _, err := readMetadataMember(f) | ||
| 161 | if err != nil { | ||
| 162 | return Metadata{}, fmt.Errorf("archive: %s %w", path, err) | ||
| 163 | } | ||
| 164 | return meta, nil | ||
| 165 | } | ||
| 166 | |||
| 167 | // ExtractData reads the .tx9 file at path and writes its second tar member | ||
| 168 | // (the data payload — gzip tar, or GPG-wrapped gzip tar if the metadata | ||
| 169 | // says Encrypted) verbatim to destFile. Returns the metadata read along the | ||
| 170 | // way, so callers don't need a separate ReadMetadata call. | ||
| 171 | func ExtractData(path, destFile string) (Metadata, error) { | ||
| 172 | f, err := os.Open(path) | ||
| 173 | if err != nil { | ||
| 174 | return Metadata{}, fmt.Errorf("archive: open %s: %w", path, err) | ||
| 175 | } | ||
| 176 | defer f.Close() | ||
| 177 | |||
| 178 | meta, tr, err := readMetadataMember(f) | ||
| 179 | if err != nil { | ||
| 180 | return Metadata{}, fmt.Errorf("archive: %s %w", path, err) | ||
| 181 | } | ||
| 182 | |||
| 183 | hdr, err := tr.Next() | ||
| 184 | if err != nil { | ||
| 185 | return Metadata{}, fmt.Errorf("archive: %s: missing data member: %w", path, err) | ||
| 186 | } | ||
| 187 | wantName := dataMemberName(meta.Encrypted) | ||
| 188 | if hdr.Name != wantName { | ||
| 189 | return Metadata{}, fmt.Errorf("archive: %s: expected data member %q, got %q", path, wantName, hdr.Name) | ||
| 190 | } | ||
| 191 | |||
| 192 | out, err := os.OpenFile(destFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | ||
| 193 | if err != nil { | ||
| 194 | return Metadata{}, fmt.Errorf("archive: create %s: %w", destFile, err) | ||
| 195 | } | ||
| 196 | defer out.Close() | ||
| 197 | if _, err := io.Copy(out, tr); err != nil { | ||
| 198 | return Metadata{}, fmt.Errorf("archive: extract data from %s: %w", path, err) | ||
| 199 | } | ||
| 200 | return meta, nil | ||
| 201 | } | ||
| @@ -0,0 +1,170 @@ | |||
| 1 | package archive | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "archive/tar" | ||
| 5 | "bytes" | ||
| 6 | "io" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "testing" | ||
| 10 | "time" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func tarWriterForTest(t *testing.T, w io.Writer) *tar.Writer { | ||
| 14 | t.Helper() | ||
| 15 | return tar.NewWriter(w) | ||
| 16 | } | ||
| 17 | |||
| 18 | func writeTestTarMember(t *testing.T, tw *tar.Writer, name string, body []byte) { | ||
| 19 | t.Helper() | ||
| 20 | if err := tw.WriteHeader(&tar.Header{ | ||
| 21 | Typeflag: tar.TypeReg, | ||
| 22 | Name: name, | ||
| 23 | Mode: 0600, | ||
| 24 | Size: int64(len(body)), | ||
| 25 | }); err != nil { | ||
| 26 | t.Fatalf("write header %q: %v", name, err) | ||
| 27 | } | ||
| 28 | if _, err := tw.Write(body); err != nil { | ||
| 29 | t.Fatalf("write body %q: %v", name, err) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | func TestWriteTx9RoundTrip(t *testing.T) { | ||
| 34 | dir := t.TempDir() | ||
| 35 | dataFile := filepath.Join(dir, "data.tar.gz") | ||
| 36 | payload := []byte("fake gzip tar payload") | ||
| 37 | if err := os.WriteFile(dataFile, payload, 0600); err != nil { | ||
| 38 | t.Fatalf("write data file: %v", err) | ||
| 39 | } | ||
| 40 | |||
| 41 | meta := Metadata{ | ||
| 42 | BoxName: "large-cat", | ||
| 43 | CreatedAt: time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC), | ||
| 44 | CLIVersion: "dev", | ||
| 45 | ImageVersion: "dev", | ||
| 46 | Encrypted: false, | ||
| 47 | } | ||
| 48 | |||
| 49 | tx9Path := filepath.Join(dir, "archive.tx9") | ||
| 50 | out, err := os.Create(tx9Path) | ||
| 51 | if err != nil { | ||
| 52 | t.Fatalf("create tx9: %v", err) | ||
| 53 | } | ||
| 54 | if err := WriteTx9(out, meta, dataFile); err != nil { | ||
| 55 | t.Fatalf("WriteTx9: %v", err) | ||
| 56 | } | ||
| 57 | if err := out.Close(); err != nil { | ||
| 58 | t.Fatalf("close tx9: %v", err) | ||
| 59 | } | ||
| 60 | |||
| 61 | gotMeta, err := ReadMetadata(tx9Path) | ||
| 62 | if err != nil { | ||
| 63 | t.Fatalf("ReadMetadata: %v", err) | ||
| 64 | } | ||
| 65 | if gotMeta.BoxName != meta.BoxName || gotMeta.FormatVersion != FormatVersion { | ||
| 66 | t.Fatalf("metadata mismatch: got %+v", gotMeta) | ||
| 67 | } | ||
| 68 | if gotMeta.Encrypted { | ||
| 69 | t.Fatal("expected Encrypted=false") | ||
| 70 | } | ||
| 71 | |||
| 72 | extracted := filepath.Join(dir, "extracted.tar.gz") | ||
| 73 | extractedMeta, err := ExtractData(tx9Path, extracted) | ||
| 74 | if err != nil { | ||
| 75 | t.Fatalf("ExtractData: %v", err) | ||
| 76 | } | ||
| 77 | if extractedMeta.BoxName != meta.BoxName { | ||
| 78 | t.Fatalf("extracted metadata mismatch: got %+v", extractedMeta) | ||
| 79 | } | ||
| 80 | |||
| 81 | got, err := os.ReadFile(extracted) | ||
| 82 | if err != nil { | ||
| 83 | t.Fatalf("read extracted data: %v", err) | ||
| 84 | } | ||
| 85 | if !bytes.Equal(got, payload) { | ||
| 86 | t.Fatalf("extracted data mismatch: got %q, want %q", got, payload) | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | func TestWriteTx9EncryptedMemberName(t *testing.T) { | ||
| 91 | dir := t.TempDir() | ||
| 92 | dataFile := filepath.Join(dir, "data.tar.gz.gpg") | ||
| 93 | if err := os.WriteFile(dataFile, []byte("ciphertext"), 0600); err != nil { | ||
| 94 | t.Fatalf("write data file: %v", err) | ||
| 95 | } | ||
| 96 | |||
| 97 | meta := Metadata{BoxName: "x", Encrypted: true} | ||
| 98 | tx9Path := filepath.Join(dir, "archive.tx9") | ||
| 99 | out, err := os.Create(tx9Path) | ||
| 100 | if err != nil { | ||
| 101 | t.Fatalf("create: %v", err) | ||
| 102 | } | ||
| 103 | if err := WriteTx9(out, meta, dataFile); err != nil { | ||
| 104 | t.Fatalf("WriteTx9: %v", err) | ||
| 105 | } | ||
| 106 | if err := out.Close(); err != nil { | ||
| 107 | t.Fatalf("close: %v", err) | ||
| 108 | } | ||
| 109 | |||
| 110 | gotMeta, err := ReadMetadata(tx9Path) | ||
| 111 | if err != nil { | ||
| 112 | t.Fatalf("ReadMetadata: %v", err) | ||
| 113 | } | ||
| 114 | if !gotMeta.Encrypted { | ||
| 115 | t.Fatal("expected Encrypted=true") | ||
| 116 | } | ||
| 117 | |||
| 118 | extracted := filepath.Join(dir, "extracted.gpg") | ||
| 119 | if _, err := ExtractData(tx9Path, extracted); err != nil { | ||
| 120 | t.Fatalf("ExtractData: %v", err) | ||
| 121 | } | ||
| 122 | got, err := os.ReadFile(extracted) | ||
| 123 | if err != nil { | ||
| 124 | t.Fatalf("read extracted: %v", err) | ||
| 125 | } | ||
| 126 | if string(got) != "ciphertext" { | ||
| 127 | t.Fatalf("got %q", got) | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | func TestReadMetadata_NotATx9File(t *testing.T) { | ||
| 132 | dir := t.TempDir() | ||
| 133 | junk := filepath.Join(dir, "junk.tx9") | ||
| 134 | if err := os.WriteFile(junk, []byte("not a tar file at all"), 0600); err != nil { | ||
| 135 | t.Fatalf("write junk: %v", err) | ||
| 136 | } | ||
| 137 | if _, err := ReadMetadata(junk); err == nil { | ||
| 138 | t.Fatal("expected error reading metadata from non-tar file") | ||
| 139 | } | ||
| 140 | } | ||
| 141 | |||
| 142 | // TestReadMetadata_FutureFormatVersion builds a .tx9 container by hand | ||
| 143 | // (bypassing WriteTx9, which always forces the current FormatVersion) to | ||
| 144 | // confirm readMetadataMember's version gate rejects an archive from a | ||
| 145 | // hypothetical newer tx9 instead of silently misparsing it. | ||
| 146 | func TestReadMetadata_FutureFormatVersion(t *testing.T) { | ||
| 147 | dir := t.TempDir() | ||
| 148 | tx9Path := filepath.Join(dir, "archive.tx9") | ||
| 149 | |||
| 150 | metaJSON := []byte(`{"box_name":"x","format_version":999}`) | ||
| 151 | dataBody := []byte("irrelevant") | ||
| 152 | |||
| 153 | f, err := os.Create(tx9Path) | ||
| 154 | if err != nil { | ||
| 155 | t.Fatalf("create: %v", err) | ||
| 156 | } | ||
| 157 | tw := tarWriterForTest(t, f) | ||
| 158 | writeTestTarMember(t, tw, metadataMember, metaJSON) | ||
| 159 | writeTestTarMember(t, tw, dataMemberName(false), dataBody) | ||
| 160 | if err := tw.Close(); err != nil { | ||
| 161 | t.Fatalf("close tar: %v", err) | ||
| 162 | } | ||
| 163 | if err := f.Close(); err != nil { | ||
| 164 | t.Fatalf("close file: %v", err) | ||
| 165 | } | ||
| 166 | |||
| 167 | if _, err := ReadMetadata(tx9Path); err == nil { | ||
| 168 | t.Fatal("expected error for a future format_version") | ||
| 169 | } | ||
| 170 | } | ||
| @@ -0,0 +1,211 @@ | |||
| 1 | package archive | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "archive/tar" | ||
| 5 | "bytes" | ||
| 6 | "compress/gzip" | ||
| 7 | "errors" | ||
| 8 | "fmt" | ||
| 9 | "io" | ||
| 10 | "os" | ||
| 11 | "os/exec" | ||
| 12 | "strings" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // runGPG shells out to gpg (no Go dependency — decision: replicate boxd's | ||
| 16 | // approach exactly, dossier §6.4). The passphrase is delivered on fd 3 via | ||
| 17 | // an *os.Pipe handed to the child as ExtraFiles[0] (which os/exec always | ||
| 18 | // places at fd 3, since 0/1/2 are stdin/stdout/stderr), matching boxd's | ||
| 19 | // `3<<<"$_BOX_PASS"` here-string — never a CLI argument or a plain | ||
| 20 | // GPG_PASSPHRASE-style env var, both of which would leak via `ps` or | ||
| 21 | // /proc/<pid>/environ. | ||
| 22 | func runGPG(passphrase string, args []string, stdin io.Reader, stdout io.Writer) error { | ||
| 23 | gpgPath, err := exec.LookPath("gpg") | ||
| 24 | if err != nil { | ||
| 25 | return fmt.Errorf("gpg not found in PATH (required for encrypted archives; install gnupg, or pass --no-encrypt): %w", err) | ||
| 26 | } | ||
| 27 | |||
| 28 | pr, pw, err := os.Pipe() | ||
| 29 | if err != nil { | ||
| 30 | return fmt.Errorf("create passphrase pipe: %w", err) | ||
| 31 | } | ||
| 32 | |||
| 33 | cmd := exec.Command(gpgPath, args...) | ||
| 34 | cmd.ExtraFiles = []*os.File{pr} | ||
| 35 | cmd.Stdin = stdin | ||
| 36 | cmd.Stdout = stdout | ||
| 37 | var stderrBuf bytes.Buffer | ||
| 38 | cmd.Stderr = &stderrBuf | ||
| 39 | |||
| 40 | if err := cmd.Start(); err != nil { | ||
| 41 | pr.Close() | ||
| 42 | pw.Close() | ||
| 43 | return fmt.Errorf("start gpg: %w", err) | ||
| 44 | } | ||
| 45 | pr.Close() // child has its own dup; parent's copy isn't needed once started | ||
| 46 | |||
| 47 | if _, werr := io.WriteString(pw, passphrase+"\n"); werr != nil { | ||
| 48 | pw.Close() | ||
| 49 | cmd.Wait() | ||
| 50 | return fmt.Errorf("write passphrase to gpg: %w", werr) | ||
| 51 | } | ||
| 52 | pw.Close() | ||
| 53 | |||
| 54 | if err := cmd.Wait(); err != nil { | ||
| 55 | return fmt.Errorf("gpg failed: %w: %s", err, strings.TrimSpace(stderrBuf.String())) | ||
| 56 | } | ||
| 57 | return nil | ||
| 58 | } | ||
| 59 | |||
| 60 | // gpgArgsEncrypt/gpgArgsDecrypt are exactly boxd's gpg_enc/gpg_dec flag | ||
| 61 | // sets (dossier §6.4): GPG-compatible AES-256 symmetric encryption, batch | ||
| 62 | // mode (never blocks on a pinentry prompt), passphrase on fd 3. | ||
| 63 | func gpgArgsEncrypt(dst string) []string { | ||
| 64 | return []string{ | ||
| 65 | "--batch", "--yes", "--pinentry-mode", "loopback", "--passphrase-fd", "3", | ||
| 66 | "--symmetric", "--cipher-algo", "AES256", "-o", dst, | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | func gpgArgsDecrypt(src string) []string { | ||
| 71 | return []string{ | ||
| 72 | "--batch", "--yes", "--pinentry-mode", "loopback", "--passphrase-fd", "3", | ||
| 73 | "-d", src, | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | // Encrypt GPG-symmetric-encrypts src (read as gpg's stdin, matching boxd's | ||
| 78 | // `gpg_enc "$encrypted" <"$raw"`) to dst using passphrase. | ||
| 79 | func Encrypt(src, dst, passphrase string) error { | ||
| 80 | in, err := os.Open(src) | ||
| 81 | if err != nil { | ||
| 82 | return fmt.Errorf("archive: open %s for encryption: %w", src, err) | ||
| 83 | } | ||
| 84 | defer in.Close() | ||
| 85 | |||
| 86 | if err := runGPG(passphrase, gpgArgsEncrypt(dst), in, nil); err != nil { | ||
| 87 | return fmt.Errorf("archive: encrypt %s: %w", src, err) | ||
| 88 | } | ||
| 89 | return nil | ||
| 90 | } | ||
| 91 | |||
| 92 | // DecryptStream decrypts src (a GPG-symmetric-encrypted file) and streams | ||
| 93 | // the plaintext to w, never touching disk for the cleartext — used by | ||
| 94 | // VerifyEncrypted (dossier §6.5) and by Decrypt below. | ||
| 95 | func DecryptStream(src, passphrase string, w io.Writer) error { | ||
| 96 | if err := runGPG(passphrase, gpgArgsDecrypt(src), nil, w); err != nil { | ||
| 97 | return fmt.Errorf("archive: decrypt %s: %w", src, err) | ||
| 98 | } | ||
| 99 | return nil | ||
| 100 | } | ||
| 101 | |||
| 102 | // Decrypt GPG-symmetric-decrypts src to dst using passphrase. | ||
| 103 | func Decrypt(src, dst, passphrase string) error { | ||
| 104 | out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | ||
| 105 | if err != nil { | ||
| 106 | return fmt.Errorf("archive: create %s for decryption: %w", dst, err) | ||
| 107 | } | ||
| 108 | defer out.Close() | ||
| 109 | return DecryptStream(src, passphrase, out) | ||
| 110 | } | ||
| 111 | |||
| 112 | // IsGPG sniffs whether the file at path is GPG binary output rather than a | ||
| 113 | // plain data.tar.gz. Neither boxd nor tx9 ever pass --armor, so ciphertext | ||
| 114 | // is always binary OpenPGP with the packet-format high bit (0x80) set on | ||
| 115 | // its first byte — unambiguous against a gzip stream, whose first byte | ||
| 116 | // (0x1f) does not have that bit set. | ||
| 117 | func IsGPG(path string) bool { | ||
| 118 | f, err := os.Open(path) | ||
| 119 | if err != nil { | ||
| 120 | return false | ||
| 121 | } | ||
| 122 | defer f.Close() | ||
| 123 | var b [1]byte | ||
| 124 | if _, err := f.Read(b[:]); err != nil { | ||
| 125 | return false | ||
| 126 | } | ||
| 127 | return b[0]&0x80 != 0 | ||
| 128 | } | ||
| 129 | |||
| 130 | // VerifyEncrypted replicates boxd's post-encrypt verification (dossier | ||
| 131 | // §6.5): decrypt encPath into a pipe and list the resulting tar's contents, | ||
| 132 | // so a round-trip failure (wrong passphrase, truncated write, corrupt gpg | ||
| 133 | // output) is caught before the archive is committed to its final path — | ||
| 134 | // without ever writing cleartext to disk. | ||
| 135 | func VerifyEncrypted(encPath, passphrase string) error { | ||
| 136 | pr, pw := io.Pipe() | ||
| 137 | |||
| 138 | decErrCh := make(chan error, 1) | ||
| 139 | go func() { | ||
| 140 | decErrCh <- DecryptStream(encPath, passphrase, pw) | ||
| 141 | pw.Close() | ||
| 142 | }() | ||
| 143 | |||
| 144 | listErr := listTarGz(pr) | ||
| 145 | pr.Close() | ||
| 146 | decErr := <-decErrCh | ||
| 147 | |||
| 148 | // When listTarGz bails out early (e.g. the decrypted stream isn't a | ||
| 149 | // valid gzip/tar at all), it stops reading from pr while the decrypt | ||
| 150 | // goroutine above may still be mid-write into pw. Our own pr.Close() | ||
| 151 | // then unblocks that pending write with an io.ErrClosedPipe (surfaced | ||
| 152 | // through DecryptStream's %w-wrapped chain, ultimately from the | ||
| 153 | // os/exec-internal copy goroutine that services a non-*os.File | ||
| 154 | // cmd.Stdout). That's an artifact of us walking away early — not | ||
| 155 | // evidence of a wrong passphrase — so it must never be reported as | ||
| 156 | // such; the real diagnosis in that case is listErr's tar.gz | ||
| 157 | // classification failure. | ||
| 158 | if decErr != nil && !isClosedPipeErr(decErr) { | ||
| 159 | return fmt.Errorf("archive: verify %s: decrypt failed (wrong passphrase?): %w", encPath, decErr) | ||
| 160 | } | ||
| 161 | if listErr != nil { | ||
| 162 | return fmt.Errorf("archive: verify %s: decrypted data is not a valid tar.gz: %w", encPath, listErr) | ||
| 163 | } | ||
| 164 | if decErr != nil { | ||
| 165 | return fmt.Errorf("archive: verify %s: decrypt failed (wrong passphrase?): %w", encPath, decErr) | ||
| 166 | } | ||
| 167 | return nil | ||
| 168 | } | ||
| 169 | |||
| 170 | // isClosedPipeErr reports whether err is (or wraps) io.ErrClosedPipe. | ||
| 171 | // errors.Is normally suffices here since every layer between the os/exec | ||
| 172 | // copy goroutine and this function wraps with %w, but we also fall back to | ||
| 173 | // a plain string match in case some exec-plumbing path ever flattens the | ||
| 174 | // error into text instead of preserving the sentinel. | ||
| 175 | func isClosedPipeErr(err error) bool { | ||
| 176 | if err == nil { | ||
| 177 | return false | ||
| 178 | } | ||
| 179 | return errors.Is(err, io.ErrClosedPipe) || strings.Contains(err.Error(), "closed pipe") | ||
| 180 | } | ||
| 181 | |||
| 182 | // listTarGz reads through every entry of a gzip-compressed tar stream | ||
| 183 | // without extracting anything, erroring on the first corrupt/truncated | ||
| 184 | // entry. | ||
| 185 | func listTarGz(r io.Reader) error { | ||
| 186 | gz, err := gzip.NewReader(r) | ||
| 187 | if err != nil { | ||
| 188 | return err | ||
| 189 | } | ||
| 190 | tr := tar.NewReader(gz) | ||
| 191 | for { | ||
| 192 | if _, err := tr.Next(); err != nil { | ||
| 193 | if err != io.EOF { | ||
| 194 | gz.Close() | ||
| 195 | return err | ||
| 196 | } | ||
| 197 | break | ||
| 198 | } | ||
| 199 | } | ||
| 200 | // tar.Next() returning io.EOF only means the tar reader saw its own | ||
| 201 | // end-of-archive marker; it does not by itself force the underlying | ||
| 202 | // gzip stream to be read to its own end, so a corrupt trailing | ||
| 203 | // CRC32/ISIZE footer would otherwise go unnoticed. Drain the rest of | ||
| 204 | // the gzip stream and check gzip.Reader.Close (which validates that | ||
| 205 | // footer) so a corrupt one is still caught here. | ||
| 206 | if _, err := io.Copy(io.Discard, gz); err != nil { | ||
| 207 | gz.Close() | ||
| 208 | return err | ||
| 209 | } | ||
| 210 | return gz.Close() | ||
| 211 | } | ||
| @@ -0,0 +1,125 @@ | |||
| 1 | package archive | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "os" | ||
| 6 | "os/exec" | ||
| 7 | "path/filepath" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func requireGPG(t *testing.T) { | ||
| 13 | t.Helper() | ||
| 14 | if _, err := exec.LookPath("gpg"); err != nil { | ||
| 15 | t.Skip("gpg not installed, skipping encryption test") | ||
| 16 | } | ||
| 17 | } | ||
| 18 | |||
| 19 | func TestEncryptDecryptRoundTrip(t *testing.T) { | ||
| 20 | requireGPG(t) | ||
| 21 | |||
| 22 | dir := t.TempDir() | ||
| 23 | src := filepath.Join(dir, "plain.bin") | ||
| 24 | enc := filepath.Join(dir, "cipher.gpg") | ||
| 25 | dec := filepath.Join(dir, "roundtrip.bin") | ||
| 26 | |||
| 27 | want := []byte("tx9 archive encryption round-trip test payload") | ||
| 28 | if err := os.WriteFile(src, want, 0600); err != nil { | ||
| 29 | t.Fatalf("write plaintext: %v", err) | ||
| 30 | } | ||
| 31 | |||
| 32 | if err := Encrypt(src, enc, "correct horse battery staple"); err != nil { | ||
| 33 | t.Fatalf("encrypt: %v", err) | ||
| 34 | } | ||
| 35 | |||
| 36 | if !IsGPG(enc) { | ||
| 37 | t.Fatal("expected encrypted output to sniff as GPG") | ||
| 38 | } | ||
| 39 | if IsGPG(src) { | ||
| 40 | t.Fatal("expected plaintext not to sniff as GPG") | ||
| 41 | } | ||
| 42 | |||
| 43 | if err := Decrypt(enc, dec, "correct horse battery staple"); err != nil { | ||
| 44 | t.Fatalf("decrypt: %v", err) | ||
| 45 | } | ||
| 46 | |||
| 47 | got, err := os.ReadFile(dec) | ||
| 48 | if err != nil { | ||
| 49 | t.Fatalf("read decrypted: %v", err) | ||
| 50 | } | ||
| 51 | if !bytes.Equal(got, want) { | ||
| 52 | t.Fatalf("round-trip mismatch: got %q, want %q", got, want) | ||
| 53 | } | ||
| 54 | |||
| 55 | if err := Decrypt(enc, filepath.Join(dir, "wrong.bin"), "not the passphrase"); err == nil { | ||
| 56 | t.Fatal("expected decrypt with wrong passphrase to fail") | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | func TestVerifyEncrypted(t *testing.T) { | ||
| 61 | requireGPG(t) | ||
| 62 | |||
| 63 | dir := t.TempDir() | ||
| 64 | src := filepath.Join(dir, "data.tar.gz") | ||
| 65 | enc := filepath.Join(dir, "data.tar.gz.gpg") | ||
| 66 | |||
| 67 | entries := append(validBase(), file("home/agent/foo.txt", "hello")) | ||
| 68 | data := buildTarGz(t, entries) | ||
| 69 | if err := os.WriteFile(src, data, 0600); err != nil { | ||
| 70 | t.Fatalf("write plaintext tar.gz: %v", err) | ||
| 71 | } | ||
| 72 | |||
| 73 | if err := Encrypt(src, enc, "s3cret"); err != nil { | ||
| 74 | t.Fatalf("encrypt: %v", err) | ||
| 75 | } | ||
| 76 | |||
| 77 | if err := VerifyEncrypted(enc, "s3cret"); err != nil { | ||
| 78 | t.Fatalf("verify with correct passphrase: %v", err) | ||
| 79 | } | ||
| 80 | if err := VerifyEncrypted(enc, "wrong"); err == nil { | ||
| 81 | t.Fatal("expected verify with wrong passphrase to fail") | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | // TestVerifyEncrypted_NotTarGz guards against the wrong-passphrase | ||
| 86 | // misclassification bug: when the decrypted plaintext isn't a valid | ||
| 87 | // tar.gz at all, VerifyEncrypted's own early exit from listTarGz closes | ||
| 88 | // the pipe out from under the still-writing decrypt goroutine, which used | ||
| 89 | // to surface as an io.ErrClosedPipe reported as "wrong passphrase?" even | ||
| 90 | // though the CORRECT passphrase was used. | ||
| 91 | func TestVerifyEncrypted_NotTarGz(t *testing.T) { | ||
| 92 | requireGPG(t) | ||
| 93 | |||
| 94 | dir := t.TempDir() | ||
| 95 | src := filepath.Join(dir, "not-a-tar.bin") | ||
| 96 | enc := filepath.Join(dir, "not-a-tar.bin.gpg") | ||
| 97 | |||
| 98 | // Deliberately not a valid gzip/tar stream, and large enough that gpg | ||
| 99 | // is still writing plaintext when listTarGz gives up on the gzip | ||
| 100 | // header. | ||
| 101 | if err := os.WriteFile(src, []byte(strings.Repeat("not a tar.gz stream ", 4096)), 0600); err != nil { | ||
| 102 | t.Fatalf("write plaintext: %v", err) | ||
| 103 | } | ||
| 104 | |||
| 105 | if err := Encrypt(src, enc, "s3cret"); err != nil { | ||
| 106 | t.Fatalf("encrypt: %v", err) | ||
| 107 | } | ||
| 108 | |||
| 109 | err := VerifyEncrypted(enc, "s3cret") | ||
| 110 | if err == nil { | ||
| 111 | t.Fatal("expected verify of a non-tar.gz payload to fail") | ||
| 112 | } | ||
| 113 | if !strings.Contains(err.Error(), "not a valid tar.gz") { | ||
| 114 | t.Fatalf("expected a tar.gz classification error, got: %v", err) | ||
| 115 | } | ||
| 116 | if strings.Contains(err.Error(), "wrong passphrase") { | ||
| 117 | t.Fatalf("misreported as wrong passphrase despite using the correct one: %v", err) | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | func TestIsGPG_NotFound(t *testing.T) { | ||
| 122 | if IsGPG(filepath.Join(t.TempDir(), "does-not-exist")) { | ||
| 123 | t.Fatal("expected IsGPG to return false for a missing file") | ||
| 124 | } | ||
| 125 | } | ||
| @@ -0,0 +1,402 @@ | |||
| 1 | package archive | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "archive/tar" | ||
| 5 | "compress/gzip" | ||
| 6 | "errors" | ||
| 7 | "fmt" | ||
| 8 | "io" | ||
| 9 | "strings" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // ValidateDataTar is a check-for-check Go port of boxd's `_validate_archive` | ||
| 13 | // Python validator (dossier §6.3 — "the production implementation from | ||
| 14 | // ./box, lifted verbatim"). r must be a gzip-compressed tar stream (a | ||
| 15 | // decrypted data.tar.gz, or the plaintext one before encryption). It is the | ||
| 16 | // safety core of both backup (called after the tar is built, before | ||
| 17 | // encryption) and import (called on the decrypted payload, before anything | ||
| 18 | // is extracted onto a real volume) — do not weaken any check here without | ||
| 19 | // re-reading dossier §6.3 rule-by-rule. | ||
| 20 | // | ||
| 21 | // It returns the first violation found, matching the Python version's | ||
| 22 | // fail-fast, single-error behavior (the original never accumulates | ||
| 23 | // multiple errors; it raises immediately at first fault). | ||
| 24 | |||
| 25 | // maxArchiveMembers hard-caps how many tar entries ValidateDataTar will | ||
| 26 | // read before erroring out, so a crafted archive with millions of tiny | ||
| 27 | // entries can't exhaust memory before validation even gets a chance to | ||
| 28 | // reject it. It's a var (not a const) so tests can lower it without having | ||
| 29 | // to generate a million-entry archive. | ||
| 30 | var maxArchiveMembers = 1_000_000 | ||
| 31 | |||
| 32 | func ValidateDataTar(r io.Reader) error { | ||
| 33 | gz, err := gzip.NewReader(r) | ||
| 34 | if err != nil { | ||
| 35 | return fmt.Errorf("archive is not a valid gzip stream: %w", err) | ||
| 36 | } | ||
| 37 | |||
| 38 | tr := tar.NewReader(gz) | ||
| 39 | |||
| 40 | // members = archive.getmembers() — the Python version reads the whole | ||
| 41 | // member list eagerly before doing anything else. | ||
| 42 | var headers []*tar.Header | ||
| 43 | for { | ||
| 44 | hdr, err := tr.Next() | ||
| 45 | if err == io.EOF { | ||
| 46 | break | ||
| 47 | } | ||
| 48 | if err != nil { | ||
| 49 | gz.Close() | ||
| 50 | return fmt.Errorf("archive is corrupt: %w", err) | ||
| 51 | } | ||
| 52 | headers = append(headers, hdr) | ||
| 53 | // A crafted archive with millions of tiny entries would otherwise | ||
| 54 | // exhaust memory (the entire header slice is buffered) before any | ||
| 55 | // of the checks below ever run. maxArchiveMembers is a var, not a | ||
| 56 | // const, so tests can lower it without generating a | ||
| 57 | // million-entry archive. | ||
| 58 | if len(headers) > maxArchiveMembers { | ||
| 59 | gz.Close() | ||
| 60 | return fmt.Errorf("archive contains more than %d members", maxArchiveMembers) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | // tar.Next() returning io.EOF only means the tar reader saw its own | ||
| 64 | // end-of-archive marker; it does not by itself force the underlying | ||
| 65 | // gzip stream to be read to its own end, so a corrupt trailing | ||
| 66 | // CRC32/ISIZE footer would otherwise go unnoticed. Drain the rest of | ||
| 67 | // the gzip stream and check gzip.Reader.Close (which validates that | ||
| 68 | // footer) so a corrupt one is still caught here. | ||
| 69 | if _, err := io.Copy(io.Discard, gz); err != nil { | ||
| 70 | gz.Close() | ||
| 71 | return fmt.Errorf("archive is corrupt: %w", err) | ||
| 72 | } | ||
| 73 | if err := gz.Close(); err != nil { | ||
| 74 | return fmt.Errorf("archive is corrupt: %w", err) | ||
| 75 | } | ||
| 76 | if len(headers) == 0 { | ||
| 77 | return errors.New("archive is empty") | ||
| 78 | } | ||
| 79 | |||
| 80 | idx := &memberIndex{byName: map[string]*tarMember{}} | ||
| 81 | |||
| 82 | // First pass: per-member checks, building the ordered by_name index. | ||
| 83 | for _, hdr := range headers { | ||
| 84 | if err := safeText(hdr.Name, "archive path"); err != nil { | ||
| 85 | return err | ||
| 86 | } | ||
| 87 | |||
| 88 | absolute := strings.HasPrefix(hdr.Name, "/") | ||
| 89 | rawParts := posixPartsRaw(hdr.Name) | ||
| 90 | for _, p := range rawParts { | ||
| 91 | if p == ".." { | ||
| 92 | return errors.New("archive path escapes its extraction root") | ||
| 93 | } | ||
| 94 | } | ||
| 95 | if absolute { | ||
| 96 | return errors.New("archive path escapes its extraction root") | ||
| 97 | } | ||
| 98 | |||
| 99 | var parts []string | ||
| 100 | for _, p := range rawParts { | ||
| 101 | if p != "." { | ||
| 102 | parts = append(parts, p) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | key := strings.Join(parts, "/") | ||
| 106 | |||
| 107 | if _, exists := idx.byName[key]; exists { | ||
| 108 | return fmt.Errorf("archive contains duplicate path: %s", displayKey(key)) | ||
| 109 | } | ||
| 110 | |||
| 111 | m := &tarMember{Name: key, Linkname: hdr.Linkname} | ||
| 112 | switch hdr.Typeflag { | ||
| 113 | case tar.TypeReg, tar.TypeRegA: | ||
| 114 | m.IsFile = true | ||
| 115 | case tar.TypeDir: | ||
| 116 | m.IsDir = true | ||
| 117 | case tar.TypeSymlink: | ||
| 118 | m.IsSymlink = true | ||
| 119 | case tar.TypeLink: | ||
| 120 | m.IsHardlink = true | ||
| 121 | default: | ||
| 122 | return errors.New("archive contains a device, FIFO, socket, or unknown special file") | ||
| 123 | } | ||
| 124 | |||
| 125 | if key == "" && !m.IsDir { | ||
| 126 | return errors.New("archive root member must be a real directory") | ||
| 127 | } | ||
| 128 | |||
| 129 | if m.IsSymlink || m.IsHardlink { | ||
| 130 | if err := validateLinkTarget(m, key); err != nil { | ||
| 131 | return err | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | idx.order = append(idx.order, key) | ||
| 136 | idx.byName[key] = m | ||
| 137 | } | ||
| 138 | |||
| 139 | // Second pass: no member may be nested beneath a link ancestor. | ||
| 140 | for _, key := range idx.order { | ||
| 141 | var parts []string | ||
| 142 | if key != "" { | ||
| 143 | parts = strings.Split(key, "/") | ||
| 144 | } | ||
| 145 | for length := 1; length < len(parts); length++ { | ||
| 146 | parentKey := strings.Join(parts[:length], "/") | ||
| 147 | if parent, ok := idx.byName[parentKey]; ok { | ||
| 148 | if parent.IsSymlink || parent.IsHardlink { | ||
| 149 | return fmt.Errorf("archive member is nested beneath a link: %s", key) | ||
| 150 | } | ||
| 151 | } | ||
| 152 | } | ||
| 153 | } | ||
| 154 | |||
| 155 | // home and home/agent must exist as real (non-link) directories. | ||
| 156 | for _, required := range []string{"home", "home/agent"} { | ||
| 157 | entry, ok := idx.byName[required] | ||
| 158 | if !ok || !entry.IsDir || entry.IsSymlink || entry.IsHardlink { | ||
| 159 | return fmt.Errorf("%s must be a real directory", required) | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | // Third pass: every member's parent must resolve to a directory, and | ||
| 164 | // hardlinks must resolve to a regular file. | ||
| 165 | for _, key := range idx.order { | ||
| 166 | if key == "" { | ||
| 167 | continue | ||
| 168 | } | ||
| 169 | parent := "" | ||
| 170 | if i := strings.LastIndex(key, "/"); i >= 0 { | ||
| 171 | parent = key[:i] | ||
| 172 | } | ||
| 173 | if parent != "" { | ||
| 174 | parentEntry, err := idx.resolve(parent) | ||
| 175 | if err != nil { | ||
| 176 | return err | ||
| 177 | } | ||
| 178 | if !parentEntry.IsDir { | ||
| 179 | return fmt.Errorf("archive member parent is not a directory: %s", key) | ||
| 180 | } | ||
| 181 | } | ||
| 182 | member := idx.byName[key] | ||
| 183 | if member.IsSymlink || member.IsHardlink { | ||
| 184 | target, err := idx.resolve(key) | ||
| 185 | if err != nil { | ||
| 186 | return err | ||
| 187 | } | ||
| 188 | if member.IsHardlink && !target.IsFile { | ||
| 189 | return errors.New("archive hardlink does not resolve to a regular file") | ||
| 190 | } | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | return nil | ||
| 195 | } | ||
| 196 | |||
| 197 | // tarMember is the subset of a tar.Header ValidateDataTar's rules care | ||
| 198 | // about, keyed by its normalized path (see the key computation above). | ||
| 199 | type tarMember struct { | ||
| 200 | Name string | ||
| 201 | IsDir bool | ||
| 202 | IsFile bool | ||
| 203 | IsSymlink bool | ||
| 204 | IsHardlink bool | ||
| 205 | Linkname string | ||
| 206 | } | ||
| 207 | |||
| 208 | // memberIndex is an insertion-ordered map from normalized path to member — | ||
| 209 | // order matters because the Go port must report the same *first* violation | ||
| 210 | // the Python dict-iteration-order version would (Python 3.7+ dicts preserve | ||
| 211 | // insertion order). | ||
| 212 | type memberIndex struct { | ||
| 213 | order []string | ||
| 214 | byName map[string]*tarMember | ||
| 215 | } | ||
| 216 | |||
| 217 | // displayKey mirrors Python's `key or '.'` idiom used when reporting the | ||
| 218 | // root member by name. | ||
| 219 | func displayKey(key string) string { | ||
| 220 | if key == "" { | ||
| 221 | return "." | ||
| 222 | } | ||
| 223 | return key | ||
| 224 | } | ||
| 225 | |||
| 226 | // safeText mirrors safe_text(): rejects NUL and other C0/DEL control | ||
| 227 | // characters in a member name or link target. | ||
| 228 | func safeText(value, label string) error { | ||
| 229 | for _, r := range value { | ||
| 230 | if r < 32 || r == 127 { | ||
| 231 | return fmt.Errorf("%s contains control characters", label) | ||
| 232 | } | ||
| 233 | } | ||
| 234 | return nil | ||
| 235 | } | ||
| 236 | |||
| 237 | // posixPartsRaw splits a POSIX path into its components the way | ||
| 238 | // PurePosixPath(...).parts does before any "." filtering: repeated/leading/ | ||
| 239 | // trailing slashes collapse away, but "." segments are preserved literally | ||
| 240 | // (pathlib doesn't collapse "." either) so ".." detection above can see | ||
| 241 | // them in their original positions. It never returns the pseudo "/" root | ||
| 242 | // component pathlib.parts would include for an absolute path — callers that | ||
| 243 | // care about absoluteness check hdr.Name's leading "/" directly instead. | ||
| 244 | func posixPartsRaw(p string) []string { | ||
| 245 | var out []string | ||
| 246 | for _, seg := range strings.Split(p, "/") { | ||
| 247 | if seg == "" { | ||
| 248 | continue | ||
| 249 | } | ||
| 250 | out = append(out, seg) | ||
| 251 | } | ||
| 252 | return out | ||
| 253 | } | ||
| 254 | |||
| 255 | // linkParts mirrors link_parts(): validates the raw link target text, then | ||
| 256 | // returns its normalized path components (dropping "" and "." segments; | ||
| 257 | // ".." is kept literally so the caller can walk it). | ||
| 258 | func linkParts(value string) ([]string, error) { | ||
| 259 | if err := safeText(value, "archive link target"); err != nil { | ||
| 260 | return nil, err | ||
| 261 | } | ||
| 262 | if strings.HasPrefix(value, "/") { | ||
| 263 | return nil, errors.New("archive link target is absolute") | ||
| 264 | } | ||
| 265 | var out []string | ||
| 266 | for _, seg := range posixPartsRaw(value) { | ||
| 267 | if seg != "." { | ||
| 268 | out = append(out, seg) | ||
| 269 | } | ||
| 270 | } | ||
| 271 | return out, nil | ||
| 272 | } | ||
| 273 | |||
| 274 | // validateLinkTarget mirrors validate_link_target(): simulates walking the | ||
| 275 | // link target's ".." segments against a stack seeded with the *link's own* | ||
| 276 | // containing directory (for symlinks only — hardlink targets in this format | ||
| 277 | // are always tar-root-relative, per the original), and rejects a target | ||
| 278 | // that would underflow past the extraction root. It only proves the target | ||
| 279 | // text is lexically well-formed; resolve() (below) does the real | ||
| 280 | // existence/type checks. | ||
| 281 | func validateLinkTarget(member *tarMember, key string) error { | ||
| 282 | parts, err := linkParts(member.Linkname) | ||
| 283 | if err != nil { | ||
| 284 | return err | ||
| 285 | } | ||
| 286 | if len(parts) == 0 { | ||
| 287 | return errors.New("archive link target is empty") | ||
| 288 | } | ||
| 289 | |||
| 290 | var resolved []string | ||
| 291 | if member.IsSymlink { | ||
| 292 | resolved = keyDirParts(key) | ||
| 293 | } | ||
| 294 | for _, part := range parts { | ||
| 295 | if part == ".." { | ||
| 296 | if len(resolved) == 0 { | ||
| 297 | return errors.New("archive link target lexically escapes extraction root") | ||
| 298 | } | ||
| 299 | resolved = resolved[:len(resolved)-1] | ||
| 300 | } else { | ||
| 301 | resolved = append(resolved, part) | ||
| 302 | } | ||
| 303 | } | ||
| 304 | return nil | ||
| 305 | } | ||
| 306 | |||
| 307 | // keyDirParts mirrors `key.split("/")[:-1]`: the path components of key's | ||
| 308 | // containing directory. | ||
| 309 | func keyDirParts(key string) []string { | ||
| 310 | if key == "" { | ||
| 311 | return nil | ||
| 312 | } | ||
| 313 | parts := strings.Split(key, "/") | ||
| 314 | return parts[:len(parts)-1] | ||
| 315 | } | ||
| 316 | |||
| 317 | // resolve mirrors the Python resolve() closure: fully walks key's | ||
| 318 | // symlink/hardlink chain (if any) to the real member it ultimately names, | ||
| 319 | // with the same cycle detection and safety-bound semantics as the | ||
| 320 | // original. | ||
| 321 | func (idx *memberIndex) resolve(key string) (*tarMember, error) { | ||
| 322 | var pending []string | ||
| 323 | if key != "" { | ||
| 324 | pending = strings.Split(key, "/") | ||
| 325 | } | ||
| 326 | var resolved []string | ||
| 327 | seenStates := map[string]bool{} | ||
| 328 | steps := 0 | ||
| 329 | maxSteps := len(idx.order)*8 + 32 | ||
| 330 | |||
| 331 | for len(pending) > 0 { | ||
| 332 | steps++ | ||
| 333 | if steps > maxSteps { | ||
| 334 | return nil, errors.New("archive link chain exceeds safety bound") | ||
| 335 | } | ||
| 336 | state := resolveStateKey(resolved, pending) | ||
| 337 | if seenStates[state] { | ||
| 338 | return nil, errors.New("archive link cycle detected") | ||
| 339 | } | ||
| 340 | seenStates[state] = true | ||
| 341 | |||
| 342 | part := pending[0] | ||
| 343 | pending = pending[1:] | ||
| 344 | |||
| 345 | if part == "" || part == "." { | ||
| 346 | continue | ||
| 347 | } | ||
| 348 | if part == ".." { | ||
| 349 | if len(resolved) == 0 { | ||
| 350 | return nil, errors.New("archive link target escapes extraction root") | ||
| 351 | } | ||
| 352 | resolved = resolved[:len(resolved)-1] | ||
| 353 | continue | ||
| 354 | } | ||
| 355 | |||
| 356 | // candidate == strings.Join(append(resolved, part), "/"); computed | ||
| 357 | // without mutating resolved since it's only used for a lookup here. | ||
| 358 | candidate := part | ||
| 359 | if len(resolved) > 0 { | ||
| 360 | candidate = strings.Join(resolved, "/") + "/" + part | ||
| 361 | } | ||
| 362 | |||
| 363 | entry, ok := idx.byName[candidate] | ||
| 364 | if ok && (entry.IsSymlink || entry.IsHardlink) { | ||
| 365 | var base []string | ||
| 366 | if entry.IsSymlink { | ||
| 367 | base = append([]string{}, resolved...) | ||
| 368 | } | ||
| 369 | targetParts, err := linkParts(entry.Linkname) | ||
| 370 | if err != nil { | ||
| 371 | return nil, err | ||
| 372 | } | ||
| 373 | next := make([]string, 0, len(base)+len(targetParts)+len(pending)) | ||
| 374 | next = append(next, base...) | ||
| 375 | next = append(next, targetParts...) | ||
| 376 | next = append(next, pending...) | ||
| 377 | pending = next | ||
| 378 | resolved = nil | ||
| 379 | continue | ||
| 380 | } | ||
| 381 | |||
| 382 | if len(pending) > 0 && (!ok || !entry.IsDir) { | ||
| 383 | return nil, fmt.Errorf("archive path resolves through missing or non-directory member: %s", candidate) | ||
| 384 | } | ||
| 385 | resolved = append(resolved, part) | ||
| 386 | } | ||
| 387 | |||
| 388 | final := strings.Join(resolved, "/") | ||
| 389 | entry, ok := idx.byName[final] | ||
| 390 | if !ok { | ||
| 391 | return nil, fmt.Errorf("archive link resolves to missing member: %s", displayKey(final)) | ||
| 392 | } | ||
| 393 | return entry, nil | ||
| 394 | } | ||
| 395 | |||
| 396 | // resolveStateKey mirrors `(tuple(resolved), tuple(pending))` as a hashable | ||
| 397 | // set element. NUL is safe as an internal separator: every path component | ||
| 398 | // reaching here already passed safeText, which rejects NUL and other | ||
| 399 | // control characters. | ||
| 400 | func resolveStateKey(resolved, pending []string) string { | ||
| 401 | return strings.Join(resolved, "\x00") + "\x01" + strings.Join(pending, "\x00") | ||
| 402 | } | ||
| @@ -0,0 +1,340 @@ | |||
| 1 | package archive | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "archive/tar" | ||
| 5 | "bytes" | ||
| 6 | "compress/gzip" | ||
| 7 | "fmt" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // testEntry describes one tar member for building synthetic archives in | ||
| 13 | // tests, without going through the real docker/tar-inside-a-container path. | ||
| 14 | type testEntry struct { | ||
| 15 | Name string | ||
| 16 | Typeflag byte | ||
| 17 | Linkname string | ||
| 18 | Body string | ||
| 19 | } | ||
| 20 | |||
| 21 | func buildTarGz(t *testing.T, entries []testEntry) []byte { | ||
| 22 | t.Helper() | ||
| 23 | var buf bytes.Buffer | ||
| 24 | gz := gzip.NewWriter(&buf) | ||
| 25 | tw := tar.NewWriter(gz) | ||
| 26 | for _, e := range entries { | ||
| 27 | mode := int64(0644) | ||
| 28 | if e.Typeflag == tar.TypeDir { | ||
| 29 | mode = 0755 | ||
| 30 | } | ||
| 31 | hdr := &tar.Header{ | ||
| 32 | Name: e.Name, | ||
| 33 | Typeflag: e.Typeflag, | ||
| 34 | Linkname: e.Linkname, | ||
| 35 | Size: int64(len(e.Body)), | ||
| 36 | Mode: mode, | ||
| 37 | } | ||
| 38 | if err := tw.WriteHeader(hdr); err != nil { | ||
| 39 | t.Fatalf("write header %q: %v", e.Name, err) | ||
| 40 | } | ||
| 41 | if e.Body != "" { | ||
| 42 | if _, err := tw.Write([]byte(e.Body)); err != nil { | ||
| 43 | t.Fatalf("write body %q: %v", e.Name, err) | ||
| 44 | } | ||
| 45 | } | ||
| 46 | } | ||
| 47 | if err := tw.Close(); err != nil { | ||
| 48 | t.Fatalf("close tar: %v", err) | ||
| 49 | } | ||
| 50 | if err := gz.Close(); err != nil { | ||
| 51 | t.Fatalf("close gzip: %v", err) | ||
| 52 | } | ||
| 53 | return buf.Bytes() | ||
| 54 | } | ||
| 55 | |||
| 56 | func dir(name string) testEntry { return testEntry{Name: name, Typeflag: tar.TypeDir} } | ||
| 57 | |||
| 58 | func file(name, body string) testEntry { | ||
| 59 | return testEntry{Name: name, Typeflag: tar.TypeReg, Body: body} | ||
| 60 | } | ||
| 61 | |||
| 62 | func symlink(name, target string) testEntry { | ||
| 63 | return testEntry{Name: name, Typeflag: tar.TypeSymlink, Linkname: target} | ||
| 64 | } | ||
| 65 | |||
| 66 | func hardlink(name, target string) testEntry { | ||
| 67 | return testEntry{Name: name, Typeflag: tar.TypeLink, Linkname: target} | ||
| 68 | } | ||
| 69 | |||
| 70 | // validBase is the minimal set of members every accepted archive needs: | ||
| 71 | // a real root directory plus the required home/home-agent tree (rule 9). | ||
| 72 | func validBase() []testEntry { | ||
| 73 | return []testEntry{ | ||
| 74 | dir(""), | ||
| 75 | dir("home"), | ||
| 76 | dir("home/agent"), | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | func runValidate(t *testing.T, entries []testEntry) error { | ||
| 81 | t.Helper() | ||
| 82 | data := buildTarGz(t, entries) | ||
| 83 | return ValidateDataTar(bytes.NewReader(data)) | ||
| 84 | } | ||
| 85 | |||
| 86 | func TestValidateDataTar_AcceptsValidArchive(t *testing.T) { | ||
| 87 | entries := append(validBase(), | ||
| 88 | file("home/agent/foo.txt", "hello"), | ||
| 89 | symlink("home/agent/link", "foo.txt"), | ||
| 90 | hardlink("home/agent/hardlink", "home/agent/foo.txt"), | ||
| 91 | ) | ||
| 92 | if err := runValidate(t, entries); err != nil { | ||
| 93 | t.Fatalf("expected valid archive to pass, got: %v", err) | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | func TestValidateDataTar_AcceptsMinimalArchive(t *testing.T) { | ||
| 98 | if err := runValidate(t, validBase()); err != nil { | ||
| 99 | t.Fatalf("expected minimal home/agent-only archive to pass, got: %v", err) | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | // TestValidateDataTar_Rejects is the comprehensive table covering every | ||
| 104 | // rule enumerated in dossier §6.3 (the Python `_validate_archive` this | ||
| 105 | // validator ports), plus the extra corrupt-stream case. | ||
| 106 | func TestValidateDataTar_Rejects(t *testing.T) { | ||
| 107 | cases := []struct { | ||
| 108 | name string | ||
| 109 | entries []testEntry | ||
| 110 | wantErr string | ||
| 111 | }{ | ||
| 112 | { | ||
| 113 | name: "empty archive", | ||
| 114 | entries: nil, | ||
| 115 | wantErr: "archive is empty", | ||
| 116 | }, | ||
| 117 | { | ||
| 118 | name: "absolute path", | ||
| 119 | entries: append(validBase(), file("/etc/passwd", "x")), | ||
| 120 | wantErr: "archive path escapes its extraction root", | ||
| 121 | }, | ||
| 122 | { | ||
| 123 | name: "traversal path", | ||
| 124 | entries: append(validBase(), file("home/../../etc/passwd", "x")), | ||
| 125 | wantErr: "archive path escapes its extraction root", | ||
| 126 | }, | ||
| 127 | { | ||
| 128 | name: "control char in name", | ||
| 129 | entries: append(validBase(), file("home/agent/\x01bad", "x")), | ||
| 130 | wantErr: "archive path contains control characters", | ||
| 131 | }, | ||
| 132 | { | ||
| 133 | name: "control char in link target", | ||
| 134 | entries: append(validBase(), symlink("home/agent/link", "\x01bad")), | ||
| 135 | wantErr: "archive link target contains control characters", | ||
| 136 | }, | ||
| 137 | { | ||
| 138 | name: "duplicate path", | ||
| 139 | entries: []testEntry{ | ||
| 140 | dir(""), | ||
| 141 | dir("home/"), | ||
| 142 | dir("./home"), | ||
| 143 | }, | ||
| 144 | wantErr: "archive contains duplicate path: home", | ||
| 145 | }, | ||
| 146 | { | ||
| 147 | name: "special file (fifo)", | ||
| 148 | entries: append(validBase(), testEntry{Name: "home/agent/fifo", Typeflag: tar.TypeFifo}), | ||
| 149 | wantErr: "archive contains a device, FIFO, socket, or unknown special file", | ||
| 150 | }, | ||
| 151 | { | ||
| 152 | name: "root member not a directory", | ||
| 153 | entries: []testEntry{file(".", "x")}, | ||
| 154 | wantErr: "archive root member must be a real directory", | ||
| 155 | }, | ||
| 156 | { | ||
| 157 | name: "root member is a symlink", | ||
| 158 | entries: []testEntry{symlink(".", "somewhere")}, | ||
| 159 | wantErr: "archive root member must be a real directory", | ||
| 160 | }, | ||
| 161 | { | ||
| 162 | name: "root member is a hardlink", | ||
| 163 | entries: []testEntry{hardlink(".", "somewhere")}, | ||
| 164 | wantErr: "archive root member must be a real directory", | ||
| 165 | }, | ||
| 166 | { | ||
| 167 | name: "missing home", | ||
| 168 | entries: []testEntry{dir("")}, | ||
| 169 | wantErr: "home must be a real directory", | ||
| 170 | }, | ||
| 171 | { | ||
| 172 | name: "home is a symlink", | ||
| 173 | entries: []testEntry{ | ||
| 174 | dir(""), | ||
| 175 | symlink("home", "somewhere"), | ||
| 176 | }, | ||
| 177 | wantErr: "home must be a real directory", | ||
| 178 | }, | ||
| 179 | { | ||
| 180 | name: "missing home/agent", | ||
| 181 | entries: []testEntry{ | ||
| 182 | dir(""), | ||
| 183 | dir("home"), | ||
| 184 | }, | ||
| 185 | wantErr: "home/agent must be a real directory", | ||
| 186 | }, | ||
| 187 | { | ||
| 188 | name: "link target is absolute", | ||
| 189 | entries: append(validBase(), symlink("home/agent/link", "/etc/passwd")), | ||
| 190 | wantErr: "archive link target is absolute", | ||
| 191 | }, | ||
| 192 | { | ||
| 193 | name: "link target is empty", | ||
| 194 | entries: append(validBase(), symlink("home/agent/link", "")), | ||
| 195 | wantErr: "archive link target is empty", | ||
| 196 | }, | ||
| 197 | { | ||
| 198 | name: "link target lexically escapes root", | ||
| 199 | entries: append(validBase(), symlink("home/agent/link", "../../../etc/passwd")), | ||
| 200 | wantErr: "archive link target lexically escapes extraction root", | ||
| 201 | }, | ||
| 202 | { | ||
| 203 | name: "nested beneath a symlink", | ||
| 204 | entries: append(validBase(), | ||
| 205 | symlink("home/agent/alias", "placeholder"), | ||
| 206 | file("home/agent/alias/child", "x"), | ||
| 207 | ), | ||
| 208 | wantErr: "archive member is nested beneath a link: home/agent/alias/child", | ||
| 209 | }, | ||
| 210 | { | ||
| 211 | name: "nested beneath a hardlink", | ||
| 212 | entries: append(validBase(), | ||
| 213 | hardlink("home/agent/alias", "home/agent"), | ||
| 214 | file("home/agent/alias/child", "x"), | ||
| 215 | ), | ||
| 216 | wantErr: "archive member is nested beneath a link: home/agent/alias/child", | ||
| 217 | }, | ||
| 218 | { | ||
| 219 | name: "link cycle", | ||
| 220 | entries: []testEntry{ | ||
| 221 | dir(""), | ||
| 222 | dir("home"), | ||
| 223 | dir("home/agent"), | ||
| 224 | symlink("a", "b"), | ||
| 225 | symlink("b", "a"), | ||
| 226 | }, | ||
| 227 | wantErr: "archive link cycle detected", | ||
| 228 | }, | ||
| 229 | { | ||
| 230 | name: "link chain exceeds safety bound", | ||
| 231 | entries: []testEntry{ | ||
| 232 | dir(""), | ||
| 233 | dir("home"), | ||
| 234 | dir("home/agent"), | ||
| 235 | symlink("a", "a/a"), | ||
| 236 | }, | ||
| 237 | wantErr: "archive link chain exceeds safety bound", | ||
| 238 | }, | ||
| 239 | { | ||
| 240 | name: "link resolves to missing member", | ||
| 241 | entries: append(validBase(), symlink("orphan", "ghost")), | ||
| 242 | wantErr: "archive link resolves to missing member: ghost", | ||
| 243 | }, | ||
| 244 | { | ||
| 245 | name: "path resolves through missing member", | ||
| 246 | entries: append(validBase(), file("home/agent/x/y/child", "x")), | ||
| 247 | wantErr: "archive path resolves through missing or non-directory member: home/agent/x", | ||
| 248 | }, | ||
| 249 | { | ||
| 250 | name: "path resolves through non-directory member", | ||
| 251 | entries: append(validBase(), | ||
| 252 | file("home/agent/notadir", "x"), | ||
| 253 | file("home/agent/notadir/deeper/child", "x"), | ||
| 254 | ), | ||
| 255 | wantErr: "archive path resolves through missing or non-directory member: home/agent/notadir", | ||
| 256 | }, | ||
| 257 | { | ||
| 258 | name: "member parent is not a directory", | ||
| 259 | entries: append(validBase(), | ||
| 260 | file("home/agent/notadir", "x"), | ||
| 261 | file("home/agent/notadir/child", "x"), | ||
| 262 | ), | ||
| 263 | wantErr: "archive member parent is not a directory: home/agent/notadir/child", | ||
| 264 | }, | ||
| 265 | { | ||
| 266 | name: "hardlink does not resolve to a regular file", | ||
| 267 | entries: append(validBase(), hardlink("home/agent/hardlink", "home/agent")), | ||
| 268 | wantErr: "archive hardlink does not resolve to a regular file", | ||
| 269 | }, | ||
| 270 | } | ||
| 271 | |||
| 272 | for _, tc := range cases { | ||
| 273 | t.Run(tc.name, func(t *testing.T) { | ||
| 274 | err := runValidate(t, tc.entries) | ||
| 275 | if err == nil { | ||
| 276 | t.Fatalf("expected error containing %q, got nil", tc.wantErr) | ||
| 277 | } | ||
| 278 | if !strings.Contains(err.Error(), tc.wantErr) { | ||
| 279 | t.Fatalf("expected error containing %q, got: %v", tc.wantErr, err) | ||
| 280 | } | ||
| 281 | }) | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | func TestValidateDataTar_NotGzip(t *testing.T) { | ||
| 286 | if err := ValidateDataTar(strings.NewReader("not a gzip stream")); err == nil { | ||
| 287 | t.Fatal("expected error for non-gzip input") | ||
| 288 | } | ||
| 289 | } | ||
| 290 | |||
| 291 | // TestValidateDataTar_CorruptGzipTrailer guards against a bug where | ||
| 292 | // tar.Next() returning io.EOF (its own end-of-archive marker) was treated | ||
| 293 | // as proof the whole stream was sound, even though the gzip trailer's | ||
| 294 | // CRC32/ISIZE footer had never actually been read/verified. Flipping the | ||
| 295 | // last 4 bytes of a valid archive corrupts that trailer without touching | ||
| 296 | // any tar content, so this only fails if the trailer is actually checked. | ||
| 297 | func TestValidateDataTar_CorruptGzipTrailer(t *testing.T) { | ||
| 298 | data := buildTarGz(t, append(validBase(), file("home/agent/foo.txt", "hello"))) | ||
| 299 | corrupted := append([]byte(nil), data...) | ||
| 300 | for i := len(corrupted) - 4; i < len(corrupted); i++ { | ||
| 301 | corrupted[i] ^= 0xff | ||
| 302 | } | ||
| 303 | if err := ValidateDataTar(bytes.NewReader(corrupted)); err == nil { | ||
| 304 | t.Fatal("expected error for archive with a corrupt gzip trailer") | ||
| 305 | } | ||
| 306 | } | ||
| 307 | |||
| 308 | // TestValidateDataTar_MemberCapExceeded exercises the maxArchiveMembers | ||
| 309 | // guard without needing to actually construct a million-entry archive: the | ||
| 310 | // var (not const) is lowered for the duration of the test. | ||
| 311 | func TestValidateDataTar_MemberCapExceeded(t *testing.T) { | ||
| 312 | orig := maxArchiveMembers | ||
| 313 | maxArchiveMembers = 10 | ||
| 314 | defer func() { maxArchiveMembers = orig }() | ||
| 315 | |||
| 316 | entries := validBase() | ||
| 317 | for i := 0; i < maxArchiveMembers+5; i++ { | ||
| 318 | entries = append(entries, file(fmt.Sprintf("home/agent/f%d", i), "x")) | ||
| 319 | } | ||
| 320 | err := runValidate(t, entries) | ||
| 321 | if err == nil { | ||
| 322 | t.Fatal("expected error for archive exceeding the member cap") | ||
| 323 | } | ||
| 324 | if !strings.Contains(err.Error(), "more than") { | ||
| 325 | t.Fatalf("expected a member-cap error, got: %v", err) | ||
| 326 | } | ||
| 327 | } | ||
| 328 | |||
| 329 | func TestValidateDataTar_TruncatedGzip(t *testing.T) { | ||
| 330 | // Cut the stream in half rather than trimming a few trailing bytes: the | ||
| 331 | // gzip footer alone can be dropped without tripping an error, since the | ||
| 332 | // tar reader may finish consuming all entries before the decompressor | ||
| 333 | // ever needs to validate the trailing CRC/ISIZE. Truncating mid-stream | ||
| 334 | // guarantees the tar/gzip readers hit a genuine unexpected EOF. | ||
| 335 | data := buildTarGz(t, append(validBase(), file("home/agent/foo.txt", strings.Repeat("x", 4096)))) | ||
| 336 | truncated := data[:len(data)/2] | ||
| 337 | if err := ValidateDataTar(bytes.NewReader(truncated)); err == nil { | ||
| 338 | t.Fatal("expected error for truncated archive") | ||
| 339 | } | ||
| 340 | } | ||
| @@ -0,0 +1,114 @@ | |||
| 1 | // Package assets turns the embedded build-context filesystem (see the | ||
| 2 | // root-level assets.go) into a tar stream suitable for the Docker Engine | ||
| 3 | // API's ImageBuild. It is parameterized over fs.FS rather than importing | ||
| 4 | // the embed var directly, since that var lives in package main (see | ||
| 5 | // assets.go for why) and Go forbids importing package main. | ||
| 6 | package assets | ||
| 7 | |||
| 8 | import ( | ||
| 9 | "archive/tar" | ||
| 10 | "bytes" | ||
| 11 | "fmt" | ||
| 12 | "io" | ||
| 13 | "io/fs" | ||
| 14 | "path" | ||
| 15 | "strings" | ||
| 16 | ) | ||
| 17 | |||
| 18 | // DockerfilePath is where the embedded Dockerfile lands inside the build | ||
| 19 | // context tar; pass this as the ImageBuild Dockerfile option. | ||
| 20 | const DockerfilePath = "docker/Dockerfile" | ||
| 21 | |||
| 22 | // BuildContextTar walks src and produces a tar stream matching the layout | ||
| 23 | // docker/Dockerfile's COPY instructions expect (build context = repo root: | ||
| 24 | // box.env, provision/, guest/, docker/entrypoint.sh, docker/executor-entrypoint.sh). | ||
| 25 | // | ||
| 26 | // go:embed strips the executable bit from every file, so shell scripts and | ||
| 27 | // other executables are explicitly re-marked 0755 here; everything else is | ||
| 28 | // 0644 (files) / 0755 (directories). Python bytecode caches (__pycache__, | ||
| 29 | // *.pyc) that may be present on disk under guest/ are skipped — they are | ||
| 30 | // build artifacts, not source. | ||
| 31 | func BuildContextTar(src fs.FS) (io.Reader, error) { | ||
| 32 | var buf bytes.Buffer | ||
| 33 | tw := tar.NewWriter(&buf) | ||
| 34 | |||
| 35 | err := fs.WalkDir(src, ".", func(p string, d fs.DirEntry, err error) error { | ||
| 36 | if err != nil { | ||
| 37 | return err | ||
| 38 | } | ||
| 39 | if p == "." { | ||
| 40 | return nil | ||
| 41 | } | ||
| 42 | if skip(p) { | ||
| 43 | if d.IsDir() { | ||
| 44 | return fs.SkipDir | ||
| 45 | } | ||
| 46 | return nil | ||
| 47 | } | ||
| 48 | |||
| 49 | if d.IsDir() { | ||
| 50 | hdr := &tar.Header{ | ||
| 51 | Name: p + "/", | ||
| 52 | Typeflag: tar.TypeDir, | ||
| 53 | Mode: 0o755, | ||
| 54 | } | ||
| 55 | return tw.WriteHeader(hdr) | ||
| 56 | } | ||
| 57 | |||
| 58 | data, err := fs.ReadFile(src, p) | ||
| 59 | if err != nil { | ||
| 60 | return fmt.Errorf("assets: read %s: %w", p, err) | ||
| 61 | } | ||
| 62 | hdr := &tar.Header{ | ||
| 63 | Name: p, | ||
| 64 | Typeflag: tar.TypeReg, | ||
| 65 | Mode: fileMode(p), | ||
| 66 | Size: int64(len(data)), | ||
| 67 | } | ||
| 68 | if err := tw.WriteHeader(hdr); err != nil { | ||
| 69 | return fmt.Errorf("assets: write header %s: %w", p, err) | ||
| 70 | } | ||
| 71 | if _, err := tw.Write(data); err != nil { | ||
| 72 | return fmt.Errorf("assets: write %s: %w", p, err) | ||
| 73 | } | ||
| 74 | return nil | ||
| 75 | }) | ||
| 76 | if err != nil { | ||
| 77 | return nil, err | ||
| 78 | } | ||
| 79 | if err := tw.Close(); err != nil { | ||
| 80 | return nil, fmt.Errorf("assets: close tar: %w", err) | ||
| 81 | } | ||
| 82 | return &buf, nil | ||
| 83 | } | ||
| 84 | |||
| 85 | // skip reports whether p is a build artifact that should not ship in the | ||
| 86 | // build context (Python bytecode caches under guest/). | ||
| 87 | func skip(p string) bool { | ||
| 88 | if strings.Contains(p, "__pycache__") { | ||
| 89 | return true | ||
| 90 | } | ||
| 91 | if path.Ext(p) == ".pyc" { | ||
| 92 | return true | ||
| 93 | } | ||
| 94 | return false | ||
| 95 | } | ||
| 96 | |||
| 97 | // fileMode returns the tar mode for path p, restoring the executable bit | ||
| 98 | // go:embed strips: any *.sh script, guest/hb and guest/hb-workload (the | ||
| 99 | // guest control binaries), guest/hermes-state, and everything under | ||
| 100 | // provision/ need 0755. Everything else is a plain 0644 data file. | ||
| 101 | func fileMode(p string) int64 { | ||
| 102 | switch { | ||
| 103 | case strings.HasSuffix(p, ".sh"): | ||
| 104 | return 0o755 | ||
| 105 | case p == "guest/hb" || strings.HasPrefix(p, "guest/hb-"): | ||
| 106 | return 0o755 | ||
| 107 | case p == "guest/hermes-state": | ||
| 108 | return 0o755 | ||
| 109 | case strings.HasPrefix(p, "provision/"): | ||
| 110 | return 0o755 | ||
| 111 | default: | ||
| 112 | return 0o644 | ||
| 113 | } | ||
| 114 | } | ||
| @@ -0,0 +1,64 @@ | |||
| 1 | package assets | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "archive/tar" | ||
| 5 | "io" | ||
| 6 | "io/fs" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "testing" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // repoRootFS builds an fs.FS over the same subset of the repo the real | ||
| 13 | // go:embed directive in the root assets.go covers, without needing to | ||
| 14 | // import package main (which is impossible — see assets.go's doc comment). | ||
| 15 | func repoRootFS(t *testing.T) fs.FS { | ||
| 16 | t.Helper() | ||
| 17 | wd, err := os.Getwd() | ||
| 18 | if err != nil { | ||
| 19 | t.Fatalf("Getwd: %v", err) | ||
| 20 | } | ||
| 21 | root := filepath.Join(wd, "..", "..") | ||
| 22 | return os.DirFS(root) | ||
| 23 | } | ||
| 24 | |||
| 25 | func TestBuildContextTar(t *testing.T) { | ||
| 26 | r, err := BuildContextTar(repoRootFS(t)) | ||
| 27 | if err != nil { | ||
| 28 | t.Fatalf("BuildContextTar: %v", err) | ||
| 29 | } | ||
| 30 | |||
| 31 | modes := map[string]int64{} | ||
| 32 | tr := tar.NewReader(r) | ||
| 33 | for { | ||
| 34 | hdr, err := tr.Next() | ||
| 35 | if err == io.EOF { | ||
| 36 | break | ||
| 37 | } | ||
| 38 | if err != nil { | ||
| 39 | t.Fatalf("tar.Next: %v", err) | ||
| 40 | } | ||
| 41 | modes[hdr.Name] = hdr.Mode | ||
| 42 | } | ||
| 43 | |||
| 44 | if _, ok := modes["docker/Dockerfile"]; !ok { | ||
| 45 | t.Errorf("docker/Dockerfile missing from build context tar") | ||
| 46 | } | ||
| 47 | mode, ok := modes["guest/hb"] | ||
| 48 | if !ok { | ||
| 49 | t.Fatalf("guest/hb missing from build context tar") | ||
| 50 | } | ||
| 51 | if mode != 0o755 { | ||
| 52 | t.Errorf("guest/hb mode = %o, want 0755", mode) | ||
| 53 | } | ||
| 54 | for _, want := range []string{"box.env", "provision/provision.sh", "docker/entrypoint.sh", "docker/executor-entrypoint.sh", "guest/hb-workload", "guest/hermes-state"} { | ||
| 55 | if _, ok := modes[want]; !ok { | ||
| 56 | t.Errorf("%s missing from build context tar", want) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | for name := range modes { | ||
| 60 | if name == "guest/__pycache__/" || name == "guest/__pycache__" { | ||
| 61 | t.Errorf("build context tar should not contain __pycache__, found %s", name) | ||
| 62 | } | ||
| 63 | } | ||
| 64 | } | ||
| @@ -0,0 +1,272 @@ | |||
| 1 | // Package box implements the tx9 box lifecycle: deriving names for a box's | ||
| 2 | // Docker objects, reconstructing box state from daemon labels, and driving | ||
| 3 | // the container topology (dossier §1) through internal/docker's Engine API | ||
| 4 | // wrapper. It is the shared contract between the lifecycle commands | ||
| 5 | // (create/list/enter/start/stop/open/doctor/delete/prune/upgrade) and the | ||
| 6 | // backup/import commands, which only ever go through this package's | ||
| 7 | // exported API, never internal/docker directly. | ||
| 8 | package box | ||
| 9 | |||
| 10 | import ( | ||
| 11 | "context" | ||
| 12 | "errors" | ||
| 13 | "fmt" | ||
| 14 | "os" | ||
| 15 | "sort" | ||
| 16 | |||
| 17 | dockernetwork "github.com/docker/docker/api/types/network" | ||
| 18 | dockerclient "github.com/docker/docker/client" | ||
| 19 | "github.com/docker/go-connections/nat" | ||
| 20 | |||
| 21 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 22 | "github.com/davis7dotsh/tx9/internal/state" | ||
| 23 | ) | ||
| 24 | |||
| 25 | // ErrNotFound is wrapped into the error Get/Exists return when no | ||
| 26 | // tx9-labeled container exists for a given box name. | ||
| 27 | var ErrNotFound = errors.New("box not found") | ||
| 28 | |||
| 29 | // executorPort is the container-side port the executor daemon always binds | ||
| 30 | // (dossier §1.1/§1.4 — fixed, never configurable). | ||
| 31 | const executorPort = "4788/tcp" | ||
| 32 | |||
| 33 | // Box is a snapshot of one box's state, reconstructed from the daemon's | ||
| 34 | // tx9-labeled containers (decision 2, tx9-cli-design.md: ~/.tx9 is a cache, | ||
| 35 | // the daemon's labels are the source of truth). | ||
| 36 | type Box struct { | ||
| 37 | Name string | ||
| 38 | AgentID string // container ID, "" if missing | ||
| 39 | ExecutorID string | ||
| 40 | AgentState string // docker state string: running/exited/... | ||
| 41 | ExecutorState string | ||
| 42 | Version string // tx9.version label value | ||
| 43 | } | ||
| 44 | |||
| 45 | // DerivedState collapses AgentState/ExecutorState into one of | ||
| 46 | // running/stopped/mixed/crashed for display (tx9 list). | ||
| 47 | func (b Box) DerivedState() string { | ||
| 48 | if b.AgentID == "" || b.ExecutorID == "" { | ||
| 49 | return "crashed" | ||
| 50 | } | ||
| 51 | agentUp := b.AgentState == "running" | ||
| 52 | execUp := b.ExecutorState == "running" | ||
| 53 | switch { | ||
| 54 | case agentUp && execUp: | ||
| 55 | return "running" | ||
| 56 | case !agentUp && !execUp: | ||
| 57 | return "stopped" | ||
| 58 | default: | ||
| 59 | return "mixed" | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | // ContainerNames returns the two container names tx9 mints for box name: | ||
| 64 | // "tx9-<name>-agent" and "tx9-<name>-executor". | ||
| 65 | func ContainerNames(name string) (agent, executor string) { | ||
| 66 | return "tx9-" + name + "-agent", "tx9-" + name + "-executor" | ||
| 67 | } | ||
| 68 | |||
| 69 | // NetworkName returns the per-box private bridge network name: "tx9-<name>". | ||
| 70 | func NetworkName(name string) string { | ||
| 71 | return "tx9-" + name | ||
| 72 | } | ||
| 73 | |||
| 74 | // VolumeNames returns the two named volumes tx9 mints for box name: | ||
| 75 | // "tx9-<name>-agent-data" (the box's durable identity) and | ||
| 76 | // "tx9-<name>-exec-data" (scratch, dossier §12 — never backed up). | ||
| 77 | func VolumeNames(name string) (agentData, execData string) { | ||
| 78 | return "tx9-" + name + "-agent-data", "tx9-" + name + "-exec-data" | ||
| 79 | } | ||
| 80 | |||
| 81 | // List returns every box this daemon knows about, reconstructed purely from | ||
| 82 | // tx9=1-labeled containers (order: sorted by name). | ||
| 83 | func List(ctx context.Context, cli *docker.Client) ([]Box, error) { | ||
| 84 | containers, err := cli.ListBoxContainers(ctx) | ||
| 85 | if err != nil { | ||
| 86 | return nil, fmt.Errorf("box: list: %w", err) | ||
| 87 | } | ||
| 88 | |||
| 89 | byName := map[string]*Box{} | ||
| 90 | var order []string | ||
| 91 | for _, c := range containers { | ||
| 92 | name := c.Labels[docker.LabelBox] | ||
| 93 | if name == "" { | ||
| 94 | continue | ||
| 95 | } | ||
| 96 | b, ok := byName[name] | ||
| 97 | if !ok { | ||
| 98 | b = &Box{Name: name} | ||
| 99 | byName[name] = b | ||
| 100 | order = append(order, name) | ||
| 101 | } | ||
| 102 | if v := c.Labels[docker.LabelVersion]; v != "" { | ||
| 103 | b.Version = v | ||
| 104 | } | ||
| 105 | switch c.Labels[docker.LabelRole] { | ||
| 106 | case docker.RoleAgent: | ||
| 107 | b.AgentID = c.ID | ||
| 108 | b.AgentState = c.State | ||
| 109 | case docker.RoleExecutor: | ||
| 110 | b.ExecutorID = c.ID | ||
| 111 | b.ExecutorState = c.State | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | sort.Strings(order) | ||
| 116 | boxes := make([]Box, 0, len(order)) | ||
| 117 | for _, name := range order { | ||
| 118 | boxes = append(boxes, *byName[name]) | ||
| 119 | } | ||
| 120 | return boxes, nil | ||
| 121 | } | ||
| 122 | |||
| 123 | // Get returns the box named name, or an error wrapping ErrNotFound if no | ||
| 124 | // tx9-labeled container exists for it. | ||
| 125 | func Get(ctx context.Context, cli *docker.Client, name string) (*Box, error) { | ||
| 126 | boxes, err := List(ctx, cli) | ||
| 127 | if err != nil { | ||
| 128 | return nil, err | ||
| 129 | } | ||
| 130 | for i := range boxes { | ||
| 131 | if boxes[i].Name == name { | ||
| 132 | return &boxes[i], nil | ||
| 133 | } | ||
| 134 | } | ||
| 135 | return nil, fmt.Errorf("box %q: %w", name, ErrNotFound) | ||
| 136 | } | ||
| 137 | |||
| 138 | // Exists reports whether a box named name has any tx9-labeled container on | ||
| 139 | // this daemon. | ||
| 140 | func Exists(ctx context.Context, cli *docker.Client, name string) (bool, error) { | ||
| 141 | _, err := Get(ctx, cli, name) | ||
| 142 | if err == nil { | ||
| 143 | return true, nil | ||
| 144 | } | ||
| 145 | if errors.Is(err, ErrNotFound) { | ||
| 146 | return false, nil | ||
| 147 | } | ||
| 148 | return false, err | ||
| 149 | } | ||
| 150 | |||
| 151 | // Token returns the box's executor bearer token from ~/.tx9/boxes/<name>.env | ||
| 152 | // (dossier §2.2 — the host-side cache; the daemon has no label for secrets). | ||
| 153 | func Token(name string) (string, error) { | ||
| 154 | env, err := state.ReadBoxEnv(name) | ||
| 155 | if err != nil { | ||
| 156 | return "", fmt.Errorf("box: token %s: %w", name, err) | ||
| 157 | } | ||
| 158 | tok := env["EXECUTOR_MCP_TOKEN"] | ||
| 159 | if tok == "" { | ||
| 160 | return "", fmt.Errorf("box: token %s: EXECUTOR_MCP_TOKEN not found in ~/.tx9/boxes/%s.env", name, name) | ||
| 161 | } | ||
| 162 | return tok, nil | ||
| 163 | } | ||
| 164 | |||
| 165 | // Start starts both containers, executor first so the agent's dependent | ||
| 166 | // services find it already listening (mirrors compose's depends_on, dossier | ||
| 167 | // §1.1). | ||
| 168 | func Start(ctx context.Context, cli *docker.Client, b *Box) error { | ||
| 169 | if b.AgentID == "" || b.ExecutorID == "" { | ||
| 170 | return fmt.Errorf("box: start %s: missing container(s) (agent=%q executor=%q)", b.Name, b.AgentID, b.ExecutorID) | ||
| 171 | } | ||
| 172 | if err := cli.ContainerStart(ctx, b.ExecutorID); err != nil { | ||
| 173 | return fmt.Errorf("box: start %s: executor: %w", b.Name, err) | ||
| 174 | } | ||
| 175 | if err := cli.ContainerStart(ctx, b.AgentID); err != nil { | ||
| 176 | return fmt.Errorf("box: start %s: agent: %w", b.Name, err) | ||
| 177 | } | ||
| 178 | return nil | ||
| 179 | } | ||
| 180 | |||
| 181 | // Stop stops both containers, agent first (the reverse of Start) so it gets | ||
| 182 | // a clean chance to shut down before its executor dependency disappears. | ||
| 183 | func Stop(ctx context.Context, cli *docker.Client, b *Box) error { | ||
| 184 | var errs []error | ||
| 185 | if b.AgentID != "" { | ||
| 186 | if err := cli.ContainerStop(ctx, b.AgentID, nil); err != nil { | ||
| 187 | errs = append(errs, fmt.Errorf("agent: %w", err)) | ||
| 188 | } | ||
| 189 | } | ||
| 190 | if b.ExecutorID != "" { | ||
| 191 | if err := cli.ContainerStop(ctx, b.ExecutorID, nil); err != nil { | ||
| 192 | errs = append(errs, fmt.Errorf("executor: %w", err)) | ||
| 193 | } | ||
| 194 | } | ||
| 195 | if len(errs) > 0 { | ||
| 196 | return fmt.Errorf("box: stop %s: %w", b.Name, errors.Join(errs...)) | ||
| 197 | } | ||
| 198 | return nil | ||
| 199 | } | ||
| 200 | |||
| 201 | // HostPort returns the published host port of the executor's 4788/tcp | ||
| 202 | // (dossier §1.4 — Docker auto-assigns this at container start since the | ||
| 203 | // spec pins no host port). | ||
| 204 | func HostPort(ctx context.Context, cli *docker.Client, b *Box) (string, error) { | ||
| 205 | if b.ExecutorID == "" { | ||
| 206 | return "", fmt.Errorf("box: host port %s: executor container missing", b.Name) | ||
| 207 | } | ||
| 208 | inspect, err := cli.ContainerInspect(ctx, b.ExecutorID) | ||
| 209 | if err != nil { | ||
| 210 | return "", fmt.Errorf("box: host port %s: %w", b.Name, err) | ||
| 211 | } | ||
| 212 | bindings, ok := inspect.NetworkSettings.Ports[nat.Port(executorPort)] | ||
| 213 | if !ok || len(bindings) == 0 { | ||
| 214 | return "", fmt.Errorf("box: host port %s: executor port %s not published (is the box running?)", b.Name, executorPort) | ||
| 215 | } | ||
| 216 | return bindings[0].HostPort, nil | ||
| 217 | } | ||
| 218 | |||
| 219 | // URLHost returns the hostname tx9 prints in dashboard/open URLs: | ||
| 220 | // TX9_URL_HOST env override, falling back to the machine's own hostname | ||
| 221 | // (dossier §3/§4 used a hardcoded siva.davis7.space default; tx9 makes this | ||
| 222 | // configurable per-install instead). | ||
| 223 | func URLHost() string { | ||
| 224 | if h := os.Getenv("TX9_URL_HOST"); h != "" { | ||
| 225 | return h | ||
| 226 | } | ||
| 227 | if h, err := os.Hostname(); err == nil && h != "" { | ||
| 228 | return h | ||
| 229 | } | ||
| 230 | return "localhost" | ||
| 231 | } | ||
| 232 | |||
| 233 | // DashboardURL is the box's unauthenticated dashboard URL (no token) — | ||
| 234 | // what `tx9 create` prints (dossier §3 step 9): meant to be shared/logged | ||
| 235 | // freely. | ||
| 236 | func DashboardURL(port string) string { | ||
| 237 | return fmt.Sprintf("http://%s:%s/", URLHost(), port) | ||
| 238 | } | ||
| 239 | |||
| 240 | // OpenURL is the box's authenticated dashboard URL, with the bearer token | ||
| 241 | // embedded in the query string (dossier §4) — what `tx9 open` prints: a | ||
| 242 | // one-shot secret, not meant for logs. | ||
| 243 | func OpenURL(port, token string) string { | ||
| 244 | return fmt.Sprintf("http://%s:%s/?_token=%s", URLHost(), port, token) | ||
| 245 | } | ||
| 246 | |||
| 247 | // ensureNetwork returns the per-box network's ID, creating it if absent so | ||
| 248 | // Create is idempotent for this object (dossier's upgrade path recreates | ||
| 249 | // containers but keeps the network). | ||
| 250 | func ensureNetwork(ctx context.Context, cli *docker.Client, netName, boxName, ver string) (string, error) { | ||
| 251 | if insp, err := cli.Raw().NetworkInspect(ctx, netName, dockernetwork.InspectOptions{}); err == nil { | ||
| 252 | return insp.ID, nil | ||
| 253 | } else if !dockerclient.IsErrNotFound(err) { | ||
| 254 | return "", fmt.Errorf("network inspect %s: %w", netName, err) | ||
| 255 | } | ||
| 256 | id, err := cli.NetworkCreate(ctx, netName, docker.BoxLabels(boxName, ver, "")) | ||
| 257 | if err != nil { | ||
| 258 | return "", err | ||
| 259 | } | ||
| 260 | return id, nil | ||
| 261 | } | ||
| 262 | |||
| 263 | // ensureVolume returns volName, creating it if absent so Create is | ||
| 264 | // idempotent for volumes (same rationale as ensureNetwork). | ||
| 265 | func ensureVolume(ctx context.Context, cli *docker.Client, volName, boxName, ver string) (string, error) { | ||
| 266 | if _, err := cli.Raw().VolumeInspect(ctx, volName); err == nil { | ||
| 267 | return volName, nil | ||
| 268 | } else if !dockerclient.IsErrNotFound(err) { | ||
| 269 | return "", fmt.Errorf("volume inspect %s: %w", volName, err) | ||
| 270 | } | ||
| 271 | return cli.VolumeCreate(ctx, volName, docker.BoxLabels(boxName, ver, "")) | ||
| 272 | } | ||
| @@ -0,0 +1,107 @@ | |||
| 1 | package box | ||
| 2 | |||
| 3 | import "testing" | ||
| 4 | |||
| 5 | func TestContainerNames(t *testing.T) { | ||
| 6 | agent, executor := ContainerNames("dry1") | ||
| 7 | if agent != "tx9-dry1-agent" { | ||
| 8 | t.Errorf("agent name = %q, want tx9-dry1-agent", agent) | ||
| 9 | } | ||
| 10 | if executor != "tx9-dry1-executor" { | ||
| 11 | t.Errorf("executor name = %q, want tx9-dry1-executor", executor) | ||
| 12 | } | ||
| 13 | } | ||
| 14 | |||
| 15 | func TestNetworkName(t *testing.T) { | ||
| 16 | if got := NetworkName("dry1"); got != "tx9-dry1" { | ||
| 17 | t.Errorf("NetworkName = %q, want tx9-dry1", got) | ||
| 18 | } | ||
| 19 | } | ||
| 20 | |||
| 21 | func TestVolumeNames(t *testing.T) { | ||
| 22 | agentVol, execVol := VolumeNames("dry1") | ||
| 23 | if agentVol != "tx9-dry1-agent-data" { | ||
| 24 | t.Errorf("agent volume = %q, want tx9-dry1-agent-data", agentVol) | ||
| 25 | } | ||
| 26 | if execVol != "tx9-dry1-exec-data" { | ||
| 27 | t.Errorf("executor volume = %q, want tx9-dry1-exec-data", execVol) | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | func TestDerivedState(t *testing.T) { | ||
| 32 | cases := []struct { | ||
| 33 | name string | ||
| 34 | b Box | ||
| 35 | want string | ||
| 36 | }{ | ||
| 37 | { | ||
| 38 | name: "both running", | ||
| 39 | b: Box{AgentID: "a", ExecutorID: "e", AgentState: "running", ExecutorState: "running"}, | ||
| 40 | want: "running", | ||
| 41 | }, | ||
| 42 | { | ||
| 43 | name: "both stopped", | ||
| 44 | b: Box{AgentID: "a", ExecutorID: "e", AgentState: "exited", ExecutorState: "exited"}, | ||
| 45 | want: "stopped", | ||
| 46 | }, | ||
| 47 | { | ||
| 48 | name: "mixed: agent up, executor down", | ||
| 49 | b: Box{AgentID: "a", ExecutorID: "e", AgentState: "running", ExecutorState: "exited"}, | ||
| 50 | want: "mixed", | ||
| 51 | }, | ||
| 52 | { | ||
| 53 | name: "mixed: executor up, agent down", | ||
| 54 | b: Box{AgentID: "a", ExecutorID: "e", AgentState: "exited", ExecutorState: "running"}, | ||
| 55 | want: "mixed", | ||
| 56 | }, | ||
| 57 | { | ||
| 58 | name: "crashed: agent container missing entirely", | ||
| 59 | b: Box{AgentID: "", ExecutorID: "e", AgentState: "", ExecutorState: "running"}, | ||
| 60 | want: "crashed", | ||
| 61 | }, | ||
| 62 | { | ||
| 63 | name: "crashed: executor container missing entirely", | ||
| 64 | b: Box{AgentID: "a", ExecutorID: "", AgentState: "running", ExecutorState: ""}, | ||
| 65 | want: "crashed", | ||
| 66 | }, | ||
| 67 | } | ||
| 68 | |||
| 69 | for _, tc := range cases { | ||
| 70 | t.Run(tc.name, func(t *testing.T) { | ||
| 71 | if got := tc.b.DerivedState(); got != tc.want { | ||
| 72 | t.Errorf("DerivedState() = %q, want %q", got, tc.want) | ||
| 73 | } | ||
| 74 | }) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | func TestURLHostEnvOverride(t *testing.T) { | ||
| 79 | t.Setenv("TX9_URL_HOST", "box.example.com") | ||
| 80 | if got := URLHost(); got != "box.example.com" { | ||
| 81 | t.Errorf("URLHost() = %q, want box.example.com", got) | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | func TestURLHostFallsBackToHostname(t *testing.T) { | ||
| 86 | t.Setenv("TX9_URL_HOST", "") | ||
| 87 | got := URLHost() | ||
| 88 | if got == "" { | ||
| 89 | t.Error("URLHost() = \"\", want a non-empty hostname or \"localhost\" fallback") | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | func TestDashboardURL(t *testing.T) { | ||
| 94 | t.Setenv("TX9_URL_HOST", "box.example.com") | ||
| 95 | if got := DashboardURL("32768"); got != "http://box.example.com:32768/" { | ||
| 96 | t.Errorf("DashboardURL() = %q, want http://box.example.com:32768/", got) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | func TestOpenURL(t *testing.T) { | ||
| 101 | t.Setenv("TX9_URL_HOST", "box.example.com") | ||
| 102 | got := OpenURL("32768", "sekret") | ||
| 103 | want := "http://box.example.com:32768/?_token=sekret" | ||
| 104 | if got != want { | ||
| 105 | t.Errorf("OpenURL() = %q, want %q", got, want) | ||
| 106 | } | ||
| 107 | } | ||
| @@ -0,0 +1,270 @@ | |||
| 1 | package box | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "io" | ||
| 8 | "strings" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | dockerclient "github.com/docker/docker/client" | ||
| 12 | "github.com/docker/go-connections/nat" | ||
| 13 | |||
| 14 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 15 | "github.com/davis7dotsh/tx9/internal/state" | ||
| 16 | "github.com/davis7dotsh/tx9/internal/version" | ||
| 17 | ) | ||
| 18 | |||
| 19 | // ipv6DisableSysctl is applied to both containers (dossier §10 gotcha #1): | ||
| 20 | // the executor daemon binds "localhost", which resolves to ::1 first in a | ||
| 21 | // default Docker container, so health checks against 127.0.0.1 would see a | ||
| 22 | // closed port unless IPv6 is disabled in the container's netns. | ||
| 23 | var ipv6DisableSysctl = map[string]string{"net.ipv6.conf.all.disable_ipv6": "1"} | ||
| 24 | |||
| 25 | // CPU/memory limits, ported verbatim from docker/compose.yaml's defaults | ||
| 26 | // (dossier §1.3/§1.4 live-inspect-confirmed): agent 4 CPUs / 8192 MiB, | ||
| 27 | // executor 2 CPUs / 2048 MiB. | ||
| 28 | const ( | ||
| 29 | agentNanoCPUs = 4_000_000_000 | ||
| 30 | agentMemoryBytes = 8192 * 1024 * 1024 | ||
| 31 | executorNanoCPUs = 2_000_000_000 | ||
| 32 | executorMemoryBytes = 2048 * 1024 * 1024 | ||
| 33 | ) | ||
| 34 | |||
| 35 | // CreateOpts parameterizes Create. | ||
| 36 | type CreateOpts struct { | ||
| 37 | Name string | ||
| 38 | Image string // e.g. "tx9-box:dev" | ||
| 39 | Token string | ||
| 40 | NoStart bool // create network/volumes/containers but do not start them (import path) | ||
| 41 | } | ||
| 42 | |||
| 43 | // Create materializes a box's network, volumes, and both containers per the | ||
| 44 | // dossier §1 topology, using tx9-* naming instead of boxd's hbl-*. Network | ||
| 45 | // and volume creation are idempotent (ensureNetwork/ensureVolume), so | ||
| 46 | // upgrade can call Create again against a box whose network/volumes already | ||
| 47 | // exist without erroring — only the containers (removed by the caller | ||
| 48 | // first) are created fresh each time. | ||
| 49 | func Create(ctx context.Context, cli *docker.Client, opts CreateOpts) (*Box, error) { | ||
| 50 | name := opts.Name | ||
| 51 | agentName, executorName := ContainerNames(name) | ||
| 52 | netName := NetworkName(name) | ||
| 53 | agentVol, execVol := VolumeNames(name) | ||
| 54 | ver := version.Version | ||
| 55 | |||
| 56 | netID, err := ensureNetwork(ctx, cli, netName, name, ver) | ||
| 57 | if err != nil { | ||
| 58 | return nil, fmt.Errorf("box: create %s: %w", name, err) | ||
| 59 | } | ||
| 60 | if _, err := ensureVolume(ctx, cli, agentVol, name, ver); err != nil { | ||
| 61 | return nil, fmt.Errorf("box: create %s: %w", name, err) | ||
| 62 | } | ||
| 63 | if _, err := ensureVolume(ctx, cli, execVol, name, ver); err != nil { | ||
| 64 | return nil, fmt.Errorf("box: create %s: %w", name, err) | ||
| 65 | } | ||
| 66 | |||
| 67 | agentSpec := docker.ContainerSpec{ | ||
| 68 | Name: agentName, | ||
| 69 | Image: opts.Image, | ||
| 70 | Entrypoint: []string{"/opt/hermes-box/bin/entrypoint"}, | ||
| 71 | Env: []string{ | ||
| 72 | "EXECUTOR_HOST=executor", | ||
| 73 | "BOXD_EXECUTOR_TOKEN=" + opts.Token, | ||
| 74 | "BOX_EXECUTOR_BRIDGE_PORT=4788", | ||
| 75 | "BOX_HERMES_BRIDGE_PORT=0", | ||
| 76 | }, | ||
| 77 | Labels: docker.BoxLabels(name, ver, docker.RoleAgent), | ||
| 78 | NetworkID: netID, | ||
| 79 | NetworkAliases: []string{"agent"}, | ||
| 80 | Binds: []string{agentVol + ":/data"}, | ||
| 81 | Sysctls: ipv6DisableSysctl, | ||
| 82 | NanoCPUs: agentNanoCPUs, | ||
| 83 | MemoryBytes: agentMemoryBytes, | ||
| 84 | } | ||
| 85 | agentID, err := cli.ContainerCreate(ctx, agentSpec) | ||
| 86 | if err != nil { | ||
| 87 | return nil, fmt.Errorf("box: create %s: agent: %w", name, err) | ||
| 88 | } | ||
| 89 | |||
| 90 | execPort := nat.Port(executorPort) | ||
| 91 | executorSpec := docker.ContainerSpec{ | ||
| 92 | Name: executorName, | ||
| 93 | Image: opts.Image, | ||
| 94 | Entrypoint: []string{"/opt/hermes-box/bin/executor-entrypoint"}, | ||
| 95 | Env: []string{"EXECUTOR_MCP_TOKEN=" + opts.Token}, | ||
| 96 | Labels: docker.BoxLabels(name, ver, docker.RoleExecutor), | ||
| 97 | NetworkID: netID, | ||
| 98 | NetworkAliases: []string{"executor"}, | ||
| 99 | Binds: []string{execVol + ":/data"}, | ||
| 100 | ExposedPorts: nat.PortSet{execPort: struct{}{}}, | ||
| 101 | PortBindings: nat.PortMap{execPort: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}}, | ||
| 102 | Sysctls: ipv6DisableSysctl, | ||
| 103 | NanoCPUs: executorNanoCPUs, | ||
| 104 | MemoryBytes: executorMemoryBytes, | ||
| 105 | } | ||
| 106 | executorID, err := cli.ContainerCreate(ctx, executorSpec) | ||
| 107 | if err != nil { | ||
| 108 | // The agent container was already created above; leaving it | ||
| 109 | // behind on this failure path leaks it, which matters most on | ||
| 110 | // the upgrade path where the caller doesn't call Destroy on | ||
| 111 | // error. Best-effort force-remove it (ignoring not-found) before | ||
| 112 | // returning. | ||
| 113 | if rmErr := cli.ContainerRemove(ctx, agentID, true); rmErr != nil && !dockerclient.IsErrNotFound(rmErr) { | ||
| 114 | return nil, fmt.Errorf("box: create %s: executor: %w (also failed to remove leaked agent container: %v)", name, err, rmErr) | ||
| 115 | } | ||
| 116 | return nil, fmt.Errorf("box: create %s: executor: %w", name, err) | ||
| 117 | } | ||
| 118 | |||
| 119 | b := &Box{ | ||
| 120 | Name: name, | ||
| 121 | AgentID: agentID, | ||
| 122 | ExecutorID: executorID, | ||
| 123 | AgentState: "created", | ||
| 124 | ExecutorState: "created", | ||
| 125 | Version: ver, | ||
| 126 | } | ||
| 127 | |||
| 128 | if !opts.NoStart { | ||
| 129 | if err := Start(ctx, cli, b); err != nil { | ||
| 130 | return b, fmt.Errorf("box: create %s: %w", name, err) | ||
| 131 | } | ||
| 132 | b.AgentState = "running" | ||
| 133 | b.ExecutorState = "running" | ||
| 134 | } | ||
| 135 | return b, nil | ||
| 136 | } | ||
| 137 | |||
| 138 | // HB runs `hb <args...>` inside the agent container, exactly replicating | ||
| 139 | // boxd's `_guest_hb` remote-control pattern (dossier §3): `runuser` drops to | ||
| 140 | // the agent user, and BOXD_EXECUTOR_TOKEN takes precedence over whatever | ||
| 141 | // stale token might be baked into the box's restored on-disk | ||
| 142 | // executor-mcp.env (dossier §2.3/§7.4 — the real bug this env override | ||
| 143 | // exists to fix). | ||
| 144 | func HB(ctx context.Context, cli *docker.Client, b *Box, token string, stdout, stderr io.Writer, args ...string) error { | ||
| 145 | if b.AgentID == "" { | ||
| 146 | return fmt.Errorf("box: hb %s %v: agent container missing", b.Name, args) | ||
| 147 | } | ||
| 148 | cmd := append([]string{ | ||
| 149 | "runuser", "-u", "agent", "--", | ||
| 150 | "env", "HOME=/data/home/agent", | ||
| 151 | "EXECUTOR_HOST=executor", | ||
| 152 | "BOXD_EXECUTOR_TOKEN=" + token, | ||
| 153 | "bash", "--noprofile", "--norc", "-c", | ||
| 154 | `. /etc/profile.d/hermes-box.sh; exec hb "$@"`, | ||
| 155 | "hb", | ||
| 156 | }, args...) | ||
| 157 | |||
| 158 | res, err := cli.Exec(ctx, b.AgentID, cmd, nil, "") | ||
| 159 | if err != nil { | ||
| 160 | return fmt.Errorf("box: hb %s %v: %w", b.Name, args, err) | ||
| 161 | } | ||
| 162 | if stdout != nil && res.Stdout != "" { | ||
| 163 | io.WriteString(stdout, res.Stdout) | ||
| 164 | } | ||
| 165 | if stderr != nil && res.Stderr != "" { | ||
| 166 | io.WriteString(stderr, res.Stderr) | ||
| 167 | } | ||
| 168 | if res.ExitCode != 0 { | ||
| 169 | return fmt.Errorf("box: hb %s %v: exit %d: %s", b.Name, args, res.ExitCode, strings.TrimSpace(res.Stderr)) | ||
| 170 | } | ||
| 171 | return nil | ||
| 172 | } | ||
| 173 | |||
| 174 | // PrepareRuntime is the post-create/post-upgrade readiness gate (dossier §3 | ||
| 175 | // step 8 / _prepare_runtime): `hb up` gets up to 6 attempts with a 15s sleep | ||
| 176 | // between them, because a freshly-started container needs real wall-clock | ||
| 177 | // time before its daemon lock holders (hb-workload's reconcile loop) settle | ||
| 178 | // (dossier §10 gotcha #2). Only once `hb up` succeeds does it run | ||
| 179 | // `hb wire-once` then `hb doctor`. | ||
| 180 | func PrepareRuntime(ctx context.Context, cli *docker.Client, b *Box, token string, out io.Writer) error { | ||
| 181 | const attempts = 6 | ||
| 182 | const retryWait = 15 * time.Second | ||
| 183 | |||
| 184 | var upErr error | ||
| 185 | for attempt := 1; attempt <= attempts; attempt++ { | ||
| 186 | upErr = HB(ctx, cli, b, token, out, out, "up") | ||
| 187 | if upErr == nil { | ||
| 188 | break | ||
| 189 | } | ||
| 190 | fmt.Fprintf(out, "box %s: hb up attempt %d/%d failed: %v\n", b.Name, attempt, attempts, upErr) | ||
| 191 | if attempt < attempts { | ||
| 192 | select { | ||
| 193 | case <-time.After(retryWait): | ||
| 194 | case <-ctx.Done(): | ||
| 195 | return fmt.Errorf("box: prepare runtime %s: %w", b.Name, ctx.Err()) | ||
| 196 | } | ||
| 197 | } | ||
| 198 | } | ||
| 199 | if upErr != nil { | ||
| 200 | return fmt.Errorf("box: prepare runtime %s: hb up did not succeed after %d attempts: %w", b.Name, attempts, upErr) | ||
| 201 | } | ||
| 202 | |||
| 203 | if err := HB(ctx, cli, b, token, out, out, "wire-once"); err != nil { | ||
| 204 | return fmt.Errorf("box: prepare runtime %s: wire-once: %w", b.Name, err) | ||
| 205 | } | ||
| 206 | if err := HB(ctx, cli, b, token, out, out, "doctor"); err != nil { | ||
| 207 | return fmt.Errorf("box: prepare runtime %s: doctor: %w", b.Name, err) | ||
| 208 | } | ||
| 209 | return nil | ||
| 210 | } | ||
| 211 | |||
| 212 | // Destroy removes a box's containers (force), network, volumes, and cached | ||
| 213 | // token file. It tolerates partial existence (e.g. a box that failed | ||
| 214 | // mid-create) — every step is best-effort and errors are collected rather | ||
| 215 | // than short-circuiting, so a half-created box never gets "stuck" | ||
| 216 | // undeletable. | ||
| 217 | func Destroy(ctx context.Context, cli *docker.Client, name string) error { | ||
| 218 | agentName, executorName := ContainerNames(name) | ||
| 219 | netName := NetworkName(name) | ||
| 220 | agentVol, execVol := VolumeNames(name) | ||
| 221 | |||
| 222 | var errs []error | ||
| 223 | |||
| 224 | b, err := Get(ctx, cli, name) | ||
| 225 | switch { | ||
| 226 | case err == nil: | ||
| 227 | if b.AgentID != "" { | ||
| 228 | if rmErr := cli.ContainerRemove(ctx, b.AgentID, true); rmErr != nil && !dockerclient.IsErrNotFound(rmErr) { | ||
| 229 | errs = append(errs, rmErr) | ||
| 230 | } | ||
| 231 | } | ||
| 232 | if b.ExecutorID != "" { | ||
| 233 | if rmErr := cli.ContainerRemove(ctx, b.ExecutorID, true); rmErr != nil && !dockerclient.IsErrNotFound(rmErr) { | ||
| 234 | errs = append(errs, rmErr) | ||
| 235 | } | ||
| 236 | } | ||
| 237 | case errors.Is(err, ErrNotFound): | ||
| 238 | // No labeled containers found via List (e.g. labels never got | ||
| 239 | // applied because create failed very early) — fall back to | ||
| 240 | // removing by the names we would have minted, best-effort. | ||
| 241 | for _, cn := range []string{agentName, executorName} { | ||
| 242 | if rmErr := cli.ContainerRemove(ctx, cn, true); rmErr != nil && !dockerclient.IsErrNotFound(rmErr) { | ||
| 243 | errs = append(errs, rmErr) | ||
| 244 | } | ||
| 245 | } | ||
| 246 | default: | ||
| 247 | errs = append(errs, err) | ||
| 248 | } | ||
| 249 | |||
| 250 | if rmErr := cli.NetworkRemove(ctx, netName); rmErr != nil && !dockerclient.IsErrNotFound(rmErr) { | ||
| 251 | errs = append(errs, rmErr) | ||
| 252 | } | ||
| 253 | for _, vn := range []string{agentVol, execVol} { | ||
| 254 | if rmErr := cli.VolumeRemove(ctx, vn, true); rmErr != nil && !dockerclient.IsErrNotFound(rmErr) { | ||
| 255 | errs = append(errs, rmErr) | ||
| 256 | } | ||
| 257 | } | ||
| 258 | if len(errs) > 0 { | ||
| 259 | return fmt.Errorf("box: destroy %s: %w", name, errors.Join(errs...)) | ||
| 260 | } | ||
| 261 | |||
| 262 | // Only drop the token env file once every other removal above | ||
| 263 | // succeeded — removing it unconditionally would strand a box that | ||
| 264 | // still exists (e.g. because a volume remove failed) without the | ||
| 265 | // token it needs. | ||
| 266 | if rmErr := state.RemoveBoxEnv(name); rmErr != nil { | ||
| 267 | return fmt.Errorf("box: destroy %s: %w", name, rmErr) | ||
| 268 | } | ||
| 269 | return nil | ||
| 270 | } | ||
| @@ -0,0 +1,109 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "flag" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | "strings" | ||
| 9 | |||
| 10 | "github.com/davis7dotsh/tx9/internal/assets" | ||
| 11 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 12 | "github.com/davis7dotsh/tx9/internal/lock" | ||
| 13 | "github.com/davis7dotsh/tx9/internal/state" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // parseFlagsAnywhere parses fs accepting flags before or after positional | ||
| 17 | // arguments (stdlib flag stops at the first positional, which silently | ||
| 18 | // ignores trailing flags like `tx9 delete mybox --force`). Everything after | ||
| 19 | // a literal "--" stays positional. | ||
| 20 | func parseFlagsAnywhere(fs *flag.FlagSet, args []string) error { | ||
| 21 | var flags, positionals []string | ||
| 22 | rest := false | ||
| 23 | for i := 0; i < len(args); i++ { | ||
| 24 | a := args[i] | ||
| 25 | switch { | ||
| 26 | case rest || a == "-" || len(a) == 0 || a[0] != '-': | ||
| 27 | positionals = append(positionals, a) | ||
| 28 | case a == "--": | ||
| 29 | rest = true | ||
| 30 | default: | ||
| 31 | flags = append(flags, a) | ||
| 32 | // a bool flag never consumes the next arg; others do unless | ||
| 33 | // written as -name=value | ||
| 34 | name := strings.TrimLeft(a, "-") | ||
| 35 | if eq := strings.IndexByte(name, '='); eq >= 0 { | ||
| 36 | name = name[:eq] | ||
| 37 | } else if f := fs.Lookup(name); f != nil { | ||
| 38 | if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); !ok || !bf.IsBoolFlag() { | ||
| 39 | if i+1 < len(args) { | ||
| 40 | i++ | ||
| 41 | flags = append(flags, args[i]) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | } | ||
| 45 | } | ||
| 46 | } | ||
| 47 | return fs.Parse(append(flags, positionals...)) | ||
| 48 | } | ||
| 49 | |||
| 50 | // requireBoxName returns fs's sole positional argument as a box name, or an | ||
| 51 | // actionable usage error if it's missing or if there's more than one (e.g. | ||
| 52 | // `tx9 delete mybox typo --force` used to silently delete mybox and ignore | ||
| 53 | // the extra "typo" positional). | ||
| 54 | func requireBoxName(fs *flag.FlagSet, cmd string) (string, error) { | ||
| 55 | if fs.NArg() != 1 { | ||
| 56 | return "", fmt.Errorf("expected exactly one box name (usage: tx9 %s <box>)", cmd) | ||
| 57 | } | ||
| 58 | return fs.Arg(0), nil | ||
| 59 | } | ||
| 60 | |||
| 61 | // withDocker opens a Docker Engine API client from the environment, runs | ||
| 62 | // fn, and always closes the client afterward. | ||
| 63 | func withDocker(fn func(ctx context.Context, cli *docker.Client) error) error { | ||
| 64 | ctx := context.Background() | ||
| 65 | cli, err := docker.NewClient(ctx) | ||
| 66 | if err != nil { | ||
| 67 | return err | ||
| 68 | } | ||
| 69 | defer cli.Close() | ||
| 70 | return fn(ctx, cli) | ||
| 71 | } | ||
| 72 | |||
| 73 | // withBoxLock acquires the named box's non-blocking per-box operation lock | ||
| 74 | // (dossier §8.1: fail fast, never queue two concurrent tx9 operations | ||
| 75 | // against the same box), then runs fn with a connected Docker client. The | ||
| 76 | // lock is released and the client closed on return. | ||
| 77 | func withBoxLock(name string, fn func(ctx context.Context, cli *docker.Client) error) error { | ||
| 78 | lockPath, err := state.LockPath(name) | ||
| 79 | if err != nil { | ||
| 80 | return err | ||
| 81 | } | ||
| 82 | release, err := lock.Acquire(lockPath) | ||
| 83 | if err != nil { | ||
| 84 | return err | ||
| 85 | } | ||
| 86 | defer release() | ||
| 87 | |||
| 88 | return withDocker(fn) | ||
| 89 | } | ||
| 90 | |||
| 91 | // ensureBoxImage builds tag (e.g. "tx9-box:<version>") if the local image | ||
| 92 | // store doesn't already have it. Shared by create and upgrade: both need a | ||
| 93 | // current-version image on hand before creating containers from it, and | ||
| 94 | // the first build is the slow part (~10 min) either way. | ||
| 95 | func ensureBoxImage(ctx context.Context, cli *docker.Client, tag string) error { | ||
| 96 | haveImage, err := cli.ImageExists(ctx, tag) | ||
| 97 | if err != nil { | ||
| 98 | return err | ||
| 99 | } | ||
| 100 | if haveImage { | ||
| 101 | return nil | ||
| 102 | } | ||
| 103 | fmt.Printf("tx9: building %s (first build takes ~10 min; cached after)\n", tag) | ||
| 104 | buildCtx, err := assets.BuildContextTar(BuildContext) | ||
| 105 | if err != nil { | ||
| 106 | return err | ||
| 107 | } | ||
| 108 | return cli.BuildImage(ctx, buildCtx, tag, os.Stdout) | ||
| 109 | } | ||
| @@ -0,0 +1,53 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "io" | ||
| 6 | "testing" | ||
| 7 | ) | ||
| 8 | |||
| 9 | // Trailing flags (`tx9 delete mybox --force`) were silently ignored when | ||
| 10 | // commands called fs.Parse directly — stdlib flag stops at the first | ||
| 11 | // positional. parseFlagsAnywhere reorders so both spellings work. | ||
| 12 | func TestParseFlagsAnywhere(t *testing.T) { | ||
| 13 | cases := []struct { | ||
| 14 | name string | ||
| 15 | args []string | ||
| 16 | wantForce bool | ||
| 17 | wantPath string | ||
| 18 | wantPos []string | ||
| 19 | }{ | ||
| 20 | {"flag before positional", []string{"--force", "mybox"}, true, "", []string{"mybox"}}, | ||
| 21 | {"flag after positional", []string{"mybox", "--force"}, true, "", []string{"mybox"}}, | ||
| 22 | {"value flag after positional", []string{"mybox", "--path", "/tmp/x"}, false, "/tmp/x", []string{"mybox"}}, | ||
| 23 | {"value flag equals form", []string{"mybox", "--path=/tmp/y"}, false, "/tmp/y", []string{"mybox"}}, | ||
| 24 | {"double dash stops parsing", []string{"mybox", "--", "--force"}, false, "", []string{"mybox", "--force"}}, | ||
| 25 | {"no flags", []string{"mybox"}, false, "", []string{"mybox"}}, | ||
| 26 | } | ||
| 27 | for _, tc := range cases { | ||
| 28 | t.Run(tc.name, func(t *testing.T) { | ||
| 29 | fs := flag.NewFlagSet("test", flag.ContinueOnError) | ||
| 30 | fs.SetOutput(io.Discard) | ||
| 31 | force := fs.Bool("force", false, "") | ||
| 32 | path := fs.String("path", "", "") | ||
| 33 | if err := parseFlagsAnywhere(fs, tc.args); err != nil { | ||
| 34 | t.Fatalf("parse: %v", err) | ||
| 35 | } | ||
| 36 | if *force != tc.wantForce { | ||
| 37 | t.Errorf("force = %v, want %v", *force, tc.wantForce) | ||
| 38 | } | ||
| 39 | if *path != tc.wantPath { | ||
| 40 | t.Errorf("path = %q, want %q", *path, tc.wantPath) | ||
| 41 | } | ||
| 42 | if got := fs.Args(); len(got) != len(tc.wantPos) { | ||
| 43 | t.Fatalf("positionals = %v, want %v", got, tc.wantPos) | ||
| 44 | } else { | ||
| 45 | for i := range got { | ||
| 46 | if got[i] != tc.wantPos[i] { | ||
| 47 | t.Errorf("positional[%d] = %q, want %q", i, got[i], tc.wantPos[i]) | ||
| 48 | } | ||
| 49 | } | ||
| 50 | } | ||
| 51 | }) | ||
| 52 | } | ||
| 53 | } | ||
| @@ -0,0 +1,333 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "bytes" | ||
| 6 | "context" | ||
| 7 | "errors" | ||
| 8 | "flag" | ||
| 9 | "fmt" | ||
| 10 | "io" | ||
| 11 | "os" | ||
| 12 | "path/filepath" | ||
| 13 | "strings" | ||
| 14 | "time" | ||
| 15 | |||
| 16 | "golang.org/x/sys/unix" | ||
| 17 | |||
| 18 | "github.com/davis7dotsh/tx9/internal/archive" | ||
| 19 | "github.com/davis7dotsh/tx9/internal/box" | ||
| 20 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 21 | "github.com/davis7dotsh/tx9/internal/version" | ||
| 22 | ) | ||
| 23 | |||
| 24 | // tarExclusions is the exact --exclude set boxd's cmd_save uses (dossier | ||
| 25 | // §6.2), in order: (1) the quiesce marker itself — a restored box must not | ||
| 26 | // start life "already quiesced" for no reason (import re-arms it | ||
| 27 | // deliberately instead, dossier §7.3); (2/3) codex/hermes tmp dirs (dossier | ||
| 28 | // §10 gotcha); (4-6) package manager caches/venvs (bulk, regenerable); | ||
| 29 | // (7/8) compiled Python bytecode; (9-11) live SQLite WAL/SHM/journal | ||
| 30 | // sidecar files (checkpointed separately by `hermes-state verify | ||
| 31 | // --checkpoint` during pause, so excluding them here is safe and avoids | ||
| 32 | // restoring a torn WAL); (12-15) process-lifecycle PID/lock/state files | ||
| 33 | // meaningless outside the process that wrote them. | ||
| 34 | var tarExclusions = []string{ | ||
| 35 | "./home/agent/.config/hermes-box/quiesced", | ||
| 36 | "./home/agent/.codex/tmp", | ||
| 37 | "./home/agent/.hermes/tmp", | ||
| 38 | "*/node_modules", | ||
| 39 | "*/.venv", | ||
| 40 | "*/venv", | ||
| 41 | "*/__pycache__", | ||
| 42 | "*/.cache", | ||
| 43 | "*.pyc", | ||
| 44 | "*.pyo", | ||
| 45 | "*/.hermes/*.db-wal", | ||
| 46 | "*/.hermes/*.db-shm", | ||
| 47 | "*/.hermes/*.db-journal", | ||
| 48 | "*/gateway.pid", | ||
| 49 | "*/cron.pid", | ||
| 50 | "*/gateway.lock", | ||
| 51 | "*/processes.json", | ||
| 52 | } | ||
| 53 | |||
| 54 | // buildTarScript reproduces boxd's cmd_save tar invocation verbatim | ||
| 55 | // (dossier §6.2): `tar czf - -C /data <excludes> .`, run inside the | ||
| 56 | // running agent container so /data (the agent-data volume) is captured | ||
| 57 | // exactly as the box sees it, with `sync` first so any just-flushed | ||
| 58 | // writes are on disk before the tar starts. | ||
| 59 | func buildTarScript() string { | ||
| 60 | var b strings.Builder | ||
| 61 | b.WriteString("set -euo pipefail; umask 077; sync\n") | ||
| 62 | b.WriteString("tar czf - -C /data") | ||
| 63 | for _, ex := range tarExclusions { | ||
| 64 | fmt.Fprintf(&b, " --exclude=%q", ex) | ||
| 65 | } | ||
| 66 | b.WriteString(" .") | ||
| 67 | return b.String() | ||
| 68 | } | ||
| 69 | |||
| 70 | // cmdBackup implements `tx9 backup <box>` (alias: export), the Go port of | ||
| 71 | // boxd's cmd_save (dossier §6): quiesce -> tar /data inside the running | ||
| 72 | // agent container -> validate -> encrypt (unless --no-encrypt) -> verify | ||
| 73 | // -> assemble a .tx9 container -> place it atomically -> resume the box if | ||
| 74 | // backup was the one that paused it. | ||
| 75 | func cmdBackup(args []string) error { | ||
| 76 | fs := flag.NewFlagSet("backup", flag.ContinueOnError) | ||
| 77 | path := fs.String("path", "", "output directory (default ~/Downloads)") | ||
| 78 | password := fs.String("password", "", "archive passphrase (else TX9_PASSWORD env, else prompt)") | ||
| 79 | noEncrypt := fs.Bool("no-encrypt", false, "skip GPG encryption") | ||
| 80 | if err := parseFlagsAnywhere(fs, args); err != nil { | ||
| 81 | return err | ||
| 82 | } | ||
| 83 | name, err := requireBoxName(fs, "backup") | ||
| 84 | if err != nil { | ||
| 85 | return err | ||
| 86 | } | ||
| 87 | |||
| 88 | destDir := *path | ||
| 89 | if destDir == "" { | ||
| 90 | home, err := os.UserHomeDir() | ||
| 91 | if err != nil { | ||
| 92 | return fmt.Errorf("backup %s: %w", name, err) | ||
| 93 | } | ||
| 94 | destDir = filepath.Join(home, "Downloads") | ||
| 95 | } | ||
| 96 | if err := os.MkdirAll(destDir, 0o755); err != nil { | ||
| 97 | return fmt.Errorf("backup %s: create output directory %s: %w", name, destDir, err) | ||
| 98 | } | ||
| 99 | |||
| 100 | encrypted := !*noEncrypt | ||
| 101 | var passphrase string | ||
| 102 | if encrypted { | ||
| 103 | passphrase, err = resolvePassword(*password, true) | ||
| 104 | if err != nil { | ||
| 105 | return fmt.Errorf("backup %s: %w", name, err) | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error { | ||
| 110 | b, err := box.Get(ctx, cli, name) | ||
| 111 | if err != nil { | ||
| 112 | return fmt.Errorf("backup %s: %w", name, err) | ||
| 113 | } | ||
| 114 | if b.AgentID == "" { | ||
| 115 | return fmt.Errorf("backup %s: agent container missing", name) | ||
| 116 | } | ||
| 117 | tok, err := box.Token(name) | ||
| 118 | if err != nil { | ||
| 119 | return fmt.Errorf("backup %s: %w", name, err) | ||
| 120 | } | ||
| 121 | |||
| 122 | weQuiesced, err := quiesceForBackup(ctx, cli, b, tok) | ||
| 123 | if err != nil { | ||
| 124 | return fmt.Errorf("backup %s: %w", name, err) | ||
| 125 | } | ||
| 126 | if weQuiesced { | ||
| 127 | defer func() { | ||
| 128 | fmt.Println("tx9: resuming box") | ||
| 129 | if rerr := box.HB(ctx, cli, b, tok, os.Stdout, os.Stderr, "resume"); rerr != nil { | ||
| 130 | fmt.Fprintf(os.Stderr, "tx9: warning: failed to resume box %s after backup, resume it manually with `hb resume`: %v\n", name, rerr) | ||
| 131 | } | ||
| 132 | }() | ||
| 133 | } | ||
| 134 | |||
| 135 | // boxd's cmd_save always runs these two after quiescing, | ||
| 136 | // unconditionally (dossier §6 / git show 9a030e2^:docker/boxd | ||
| 137 | // ~L405-415) — not just when we were the ones who paused. Relying | ||
| 138 | // solely on `hb pause`'s own checkpoint would skip it whenever | ||
| 139 | // the box was already paused, and boxd's runtime-manifest write | ||
| 140 | // never happened at all, so both are run here regardless of | ||
| 141 | // weQuiesced. | ||
| 142 | fmt.Println("tx9: checkpointing state") | ||
| 143 | if err := box.HB(ctx, cli, b, tok, io.Discard, os.Stderr, "verify-state", "--checkpoint"); err != nil { | ||
| 144 | return fmt.Errorf("backup %s: %w", name, err) | ||
| 145 | } | ||
| 146 | if err := box.HB(ctx, cli, b, tok, os.Stdout, os.Stderr, "write-manifest"); err != nil { | ||
| 147 | return fmt.Errorf("backup %s: %w", name, err) | ||
| 148 | } | ||
| 149 | |||
| 150 | fmt.Println("tx9: capturing /data from the agent container") | ||
| 151 | rawFile, err := os.CreateTemp("", "tx9-backup-*.tar.gz") | ||
| 152 | if err != nil { | ||
| 153 | return fmt.Errorf("backup %s: %w", name, err) | ||
| 154 | } | ||
| 155 | rawPath := rawFile.Name() | ||
| 156 | defer os.Remove(rawPath) | ||
| 157 | |||
| 158 | var stderrBuf bytes.Buffer | ||
| 159 | exitCode, err := cli.ExecStream(ctx, b.AgentID, []string{"bash", "-c", buildTarScript()}, nil, "", rawFile, &stderrBuf) | ||
| 160 | closeErr := rawFile.Close() | ||
| 161 | if err != nil { | ||
| 162 | return fmt.Errorf("backup %s: tar exec: %w", name, err) | ||
| 163 | } | ||
| 164 | if closeErr != nil { | ||
| 165 | return fmt.Errorf("backup %s: %w", name, closeErr) | ||
| 166 | } | ||
| 167 | if exitCode != 0 { | ||
| 168 | return fmt.Errorf("backup %s: tar exited %d: %s", name, exitCode, strings.TrimSpace(stderrBuf.String())) | ||
| 169 | } | ||
| 170 | |||
| 171 | fmt.Println("tx9: validating archive contents") | ||
| 172 | if err := validateFileAsDataTar(rawPath); err != nil { | ||
| 173 | return fmt.Errorf("backup %s: refusing to save an unsafe archive: %w", name, err) | ||
| 174 | } | ||
| 175 | |||
| 176 | dataFile := rawPath | ||
| 177 | if encrypted { | ||
| 178 | fmt.Println("tx9: encrypting archive") | ||
| 179 | encPath := rawPath + ".gpg" | ||
| 180 | defer os.Remove(encPath) | ||
| 181 | if err := archive.Encrypt(rawPath, encPath, passphrase); err != nil { | ||
| 182 | return fmt.Errorf("backup %s: %w", name, err) | ||
| 183 | } | ||
| 184 | if err := archive.VerifyEncrypted(encPath, passphrase); err != nil { | ||
| 185 | return fmt.Errorf("backup %s: %w", name, err) | ||
| 186 | } | ||
| 187 | dataFile = encPath | ||
| 188 | } | ||
| 189 | |||
| 190 | ts := time.Now() | ||
| 191 | meta := archive.Metadata{ | ||
| 192 | BoxName: name, | ||
| 193 | CreatedAt: ts, | ||
| 194 | CLIVersion: version.Version, | ||
| 195 | ImageVersion: b.Version, | ||
| 196 | Encrypted: encrypted, | ||
| 197 | } | ||
| 198 | |||
| 199 | finalName := fmt.Sprintf("%s-%s.tx9", name, ts.Format("20060102-150405")) | ||
| 200 | finalPath := filepath.Join(destDir, finalName) | ||
| 201 | stagingPath := filepath.Join(destDir, fmt.Sprintf(".%s.staging-%d", finalName, os.Getpid())) | ||
| 202 | |||
| 203 | if err := writeTx9Staged(stagingPath, meta, dataFile); err != nil { | ||
| 204 | os.Remove(stagingPath) | ||
| 205 | return fmt.Errorf("backup %s: %w", name, err) | ||
| 206 | } | ||
| 207 | // ln (hard link), not mv/cp: fails loudly if finalPath already | ||
| 208 | // exists rather than silently clobbering a prior backup of the | ||
| 209 | // same name (dossier §6.5's atomic no-clobber placement). | ||
| 210 | if err := os.Link(stagingPath, finalPath); err != nil { | ||
| 211 | os.Remove(stagingPath) | ||
| 212 | return fmt.Errorf("backup %s: place archive at %s: %w", name, finalPath, err) | ||
| 213 | } | ||
| 214 | os.Remove(stagingPath) | ||
| 215 | |||
| 216 | info, statErr := os.Stat(finalPath) | ||
| 217 | if statErr != nil { | ||
| 218 | fmt.Printf("tx9: backup complete: %s\n", finalPath) | ||
| 219 | } else { | ||
| 220 | fmt.Printf("tx9: backup complete: %s (%d bytes)\n", finalPath, info.Size()) | ||
| 221 | } | ||
| 222 | return nil | ||
| 223 | }) | ||
| 224 | } | ||
| 225 | |||
| 226 | // quiesceForBackup implements dossier §6.1: check `hb is-paused`; if not | ||
| 227 | // already paused, call `hb pause` and report that the caller must resume | ||
| 228 | // afterward. A non-nil error from `is-paused` is treated as "not paused" | ||
| 229 | // (hb's own is-paused check exits non-zero in that case, mirroring boxd's | ||
| 230 | // `_guest_hb` usage). | ||
| 231 | func quiesceForBackup(ctx context.Context, cli *docker.Client, b *box.Box, token string) (weQuiesced bool, err error) { | ||
| 232 | paused := box.HB(ctx, cli, b, token, nil, nil, "is-paused") == nil | ||
| 233 | if paused { | ||
| 234 | return false, nil | ||
| 235 | } | ||
| 236 | fmt.Println("tx9: pausing box for a consistent snapshot") | ||
| 237 | if err := box.HB(ctx, cli, b, token, os.Stdout, os.Stderr, "pause"); err != nil { | ||
| 238 | return false, fmt.Errorf("pause: %w", err) | ||
| 239 | } | ||
| 240 | return true, nil | ||
| 241 | } | ||
| 242 | |||
| 243 | // validateFileAsDataTar opens path and runs archive.ValidateDataTar over it. | ||
| 244 | func validateFileAsDataTar(path string) error { | ||
| 245 | f, err := os.Open(path) | ||
| 246 | if err != nil { | ||
| 247 | return err | ||
| 248 | } | ||
| 249 | defer f.Close() | ||
| 250 | return archive.ValidateDataTar(f) | ||
| 251 | } | ||
| 252 | |||
| 253 | // writeTx9Staged assembles a complete .tx9 container at stagingPath using | ||
| 254 | // archive.WriteTx9, so the potentially-slow copy/gzip-framing work happens | ||
| 255 | // on a name nothing else can be watching before the atomic hard-link | ||
| 256 | // promotion in cmdBackup. | ||
| 257 | func writeTx9Staged(stagingPath string, meta archive.Metadata, dataFile string) error { | ||
| 258 | out, err := os.OpenFile(stagingPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | ||
| 259 | if err != nil { | ||
| 260 | return fmt.Errorf("stage archive: %w", err) | ||
| 261 | } | ||
| 262 | writeErr := archive.WriteTx9(out, meta, dataFile) | ||
| 263 | closeErr := out.Close() | ||
| 264 | if writeErr != nil { | ||
| 265 | return fmt.Errorf("stage archive: %w", writeErr) | ||
| 266 | } | ||
| 267 | if closeErr != nil { | ||
| 268 | return fmt.Errorf("stage archive: %w", closeErr) | ||
| 269 | } | ||
| 270 | return nil | ||
| 271 | } | ||
| 272 | |||
| 273 | // readPassword prompts on /dev/tty with echo disabled (dossier §6.4's | ||
| 274 | // interactive-prompt fallback), toggling ECHO off / ECHONL on exactly like | ||
| 275 | // a standard `stty -echo` password prompt, and always restoring the | ||
| 276 | // original termios settings afterward. | ||
| 277 | func readPassword(prompt string) (string, error) { | ||
| 278 | tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) | ||
| 279 | if err != nil { | ||
| 280 | return "", fmt.Errorf("read password: open /dev/tty: %w", err) | ||
| 281 | } | ||
| 282 | defer tty.Close() | ||
| 283 | |||
| 284 | fd := int(tty.Fd()) | ||
| 285 | oldState, err := unix.IoctlGetTermios(fd, unix.TCGETS) | ||
| 286 | if err != nil { | ||
| 287 | return "", fmt.Errorf("read password: get termios: %w", err) | ||
| 288 | } | ||
| 289 | newState := *oldState | ||
| 290 | newState.Lflag &^= unix.ECHO | ||
| 291 | newState.Lflag |= unix.ECHONL | ||
| 292 | if err := unix.IoctlSetTermios(fd, unix.TCSETS, &newState); err != nil { | ||
| 293 | return "", fmt.Errorf("read password: set termios: %w", err) | ||
| 294 | } | ||
| 295 | defer unix.IoctlSetTermios(fd, unix.TCSETS, oldState) | ||
| 296 | |||
| 297 | fmt.Fprint(tty, prompt) | ||
| 298 | line, err := bufio.NewReader(tty).ReadString('\n') | ||
| 299 | if err != nil && !errors.Is(err, io.EOF) { | ||
| 300 | return "", fmt.Errorf("read password: %w", err) | ||
| 301 | } | ||
| 302 | return strings.TrimRight(line, "\r\n"), nil | ||
| 303 | } | ||
| 304 | |||
| 305 | // resolvePassword implements the flag > env > interactive-prompt | ||
| 306 | // precedence used by both backup and import ("--password" flag, else | ||
| 307 | // TX9_PASSWORD env, else prompt). When confirm is true (backup minting a | ||
| 308 | // new passphrase), the operator must type it twice matching. | ||
| 309 | func resolvePassword(flagVal string, confirm bool) (string, error) { | ||
| 310 | if flagVal != "" { | ||
| 311 | return flagVal, nil | ||
| 312 | } | ||
| 313 | if env := os.Getenv("TX9_PASSWORD"); env != "" { | ||
| 314 | return env, nil | ||
| 315 | } | ||
| 316 | pw, err := readPassword("Archive passphrase: ") | ||
| 317 | if err != nil { | ||
| 318 | return "", err | ||
| 319 | } | ||
| 320 | if pw == "" { | ||
| 321 | return "", errors.New("archive passphrase must not be empty") | ||
| 322 | } | ||
| 323 | if confirm { | ||
| 324 | pw2, err := readPassword("Confirm passphrase: ") | ||
| 325 | if err != nil { | ||
| 326 | return "", err | ||
| 327 | } | ||
| 328 | if pw2 != pw { | ||
| 329 | return "", errors.New("passphrases did not match") | ||
| 330 | } | ||
| 331 | } | ||
| 332 | return pw, nil | ||
| 333 | } | ||
| @@ -0,0 +1,112 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "flag" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | |||
| 9 | "github.com/davis7dotsh/tx9/internal/box" | ||
| 10 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 11 | "github.com/davis7dotsh/tx9/internal/lock" | ||
| 12 | "github.com/davis7dotsh/tx9/internal/names" | ||
| 13 | "github.com/davis7dotsh/tx9/internal/state" | ||
| 14 | "github.com/davis7dotsh/tx9/internal/token" | ||
| 15 | "github.com/davis7dotsh/tx9/internal/version" | ||
| 16 | ) | ||
| 17 | |||
| 18 | // cmdCreate implements `tx9 create [name]` (command surface: generate a | ||
| 19 | // friendly name if absent, build tx9-box:<version> if missing, create | ||
| 20 | // network + volumes + both containers, mint token, wire MCP, run doctor). | ||
| 21 | func cmdCreate(args []string) error { | ||
| 22 | fs := flag.NewFlagSet("create", flag.ContinueOnError) | ||
| 23 | if err := parseFlagsAnywhere(fs, args); err != nil { | ||
| 24 | return err | ||
| 25 | } | ||
| 26 | if fs.NArg() > 1 { | ||
| 27 | return fmt.Errorf("create: expected at most one box name (usage: tx9 create [box])") | ||
| 28 | } | ||
| 29 | requested := fs.Arg(0) | ||
| 30 | |||
| 31 | ctx := context.Background() | ||
| 32 | cli, err := docker.NewClient(ctx) | ||
| 33 | if err != nil { | ||
| 34 | return err | ||
| 35 | } | ||
| 36 | defer cli.Close() | ||
| 37 | |||
| 38 | name := requested | ||
| 39 | if name == "" { | ||
| 40 | existing, err := box.List(ctx, cli) | ||
| 41 | if err != nil { | ||
| 42 | return fmt.Errorf("create: %w", err) | ||
| 43 | } | ||
| 44 | taken := make(map[string]bool, len(existing)) | ||
| 45 | for _, b := range existing { | ||
| 46 | taken[b.Name] = true | ||
| 47 | } | ||
| 48 | name, err = names.Generate(taken) | ||
| 49 | if err != nil { | ||
| 50 | return fmt.Errorf("create: %w", err) | ||
| 51 | } | ||
| 52 | } else if err := names.Validate(name); err != nil { | ||
| 53 | return fmt.Errorf("create: %w", err) | ||
| 54 | } | ||
| 55 | |||
| 56 | lockPath, err := state.LockPath(name) | ||
| 57 | if err != nil { | ||
| 58 | return fmt.Errorf("create %s: %w", name, err) | ||
| 59 | } | ||
| 60 | release, err := lock.Acquire(lockPath) | ||
| 61 | if err != nil { | ||
| 62 | return fmt.Errorf("create %s: %w", name, err) | ||
| 63 | } | ||
| 64 | defer release() | ||
| 65 | |||
| 66 | exists, err := box.Exists(ctx, cli, name) | ||
| 67 | if err != nil { | ||
| 68 | return fmt.Errorf("create %s: %w", name, err) | ||
| 69 | } | ||
| 70 | if exists { | ||
| 71 | return fmt.Errorf("create %s: box already exists", name) | ||
| 72 | } | ||
| 73 | |||
| 74 | imageTag := fmt.Sprintf("tx9-box:%s", version.Version) | ||
| 75 | if err := ensureBoxImage(ctx, cli, imageTag); err != nil { | ||
| 76 | return fmt.Errorf("create %s: %w", name, err) | ||
| 77 | } | ||
| 78 | |||
| 79 | tok, err := token.Mint() | ||
| 80 | if err != nil { | ||
| 81 | return fmt.Errorf("create %s: %w", name, err) | ||
| 82 | } | ||
| 83 | if err := state.WriteBoxEnv(name, map[string]string{"EXECUTOR_MCP_TOKEN": tok}); err != nil { | ||
| 84 | return fmt.Errorf("create %s: %w", name, err) | ||
| 85 | } | ||
| 86 | |||
| 87 | fmt.Printf("tx9: creating box %q (agent + executor containers, private network)\n", name) | ||
| 88 | b, err := box.Create(ctx, cli, box.CreateOpts{Name: name, Image: imageTag, Token: tok}) | ||
| 89 | if err != nil { | ||
| 90 | box.Destroy(ctx, cli, name) | ||
| 91 | return fmt.Errorf("create %s: %w", name, err) | ||
| 92 | } | ||
| 93 | |||
| 94 | fmt.Println("tx9: preparing runtime (executor reachability, MCP wiring, doctor)") | ||
| 95 | if err := box.PrepareRuntime(ctx, cli, b, tok, os.Stdout); err != nil { | ||
| 96 | box.Destroy(ctx, cli, name) | ||
| 97 | return fmt.Errorf("create %s: runtime failed, box removed: %w", name, err) | ||
| 98 | } | ||
| 99 | |||
| 100 | port, err := box.HostPort(ctx, cli, b) | ||
| 101 | if err != nil { | ||
| 102 | return fmt.Errorf("create %s: box is up but %w", name, err) | ||
| 103 | } | ||
| 104 | |||
| 105 | fmt.Printf("\nBox %q is ready. Next steps:\n", name) | ||
| 106 | fmt.Printf(" 1. tx9 enter %s\n", name) | ||
| 107 | fmt.Println(" 2. inside the box, authenticate: claude (Claude Code) and codex") | ||
| 108 | fmt.Println(" 3. when ready to go live, run (the only path that starts the gateway):") | ||
| 109 | fmt.Println(" hb gateway-enable --confirm-single-writer I_CONFIRM_NO_OTHER_GATEWAY_USES_THIS_IDENTITY") | ||
| 110 | fmt.Printf(" 4. dashboard: %s (run `tx9 open %s` for an authenticated one-shot URL)\n", box.DashboardURL(port), name) | ||
| 111 | return nil | ||
| 112 | } | ||
| @@ -0,0 +1,63 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "context" | ||
| 6 | "flag" | ||
| 7 | "fmt" | ||
| 8 | "os" | ||
| 9 | "strings" | ||
| 10 | |||
| 11 | "github.com/davis7dotsh/tx9/internal/box" | ||
| 12 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // cmdDelete implements `tx9 delete <box>`: containers + volumes + token | ||
| 16 | // file, typed-name confirmation (--force to skip). | ||
| 17 | func cmdDelete(args []string) error { | ||
| 18 | fs := flag.NewFlagSet("delete", flag.ContinueOnError) | ||
| 19 | force := fs.Bool("force", false, "skip typed-name confirmation") | ||
| 20 | if err := parseFlagsAnywhere(fs, args); err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 23 | name, err := requireBoxName(fs, "delete") | ||
| 24 | if err != nil { | ||
| 25 | return err | ||
| 26 | } | ||
| 27 | |||
| 28 | if !*force { | ||
| 29 | if err := confirmName(name); err != nil { | ||
| 30 | return err | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error { | ||
| 35 | if exists, err := box.Exists(ctx, cli, name); err != nil { | ||
| 36 | return fmt.Errorf("delete %s: %w", name, err) | ||
| 37 | } else if !exists { | ||
| 38 | return fmt.Errorf("delete %s: box does not exist", name) | ||
| 39 | } | ||
| 40 | if err := box.Destroy(ctx, cli, name); err != nil { | ||
| 41 | return fmt.Errorf("delete %s: %w", name, err) | ||
| 42 | } | ||
| 43 | fmt.Printf("tx9: box %s deleted\n", name) | ||
| 44 | return nil | ||
| 45 | }) | ||
| 46 | } | ||
| 47 | |||
| 48 | // confirmName prompts on stdin and requires the operator to type the exact | ||
| 49 | // box name back, a deliberate "destructive-by-default" friction distinct | ||
| 50 | // from a plain --yes flag (mirrors gateway_enable's confirm-phrase pattern, | ||
| 51 | // dossier §9, though delete's confirmation is the name itself). | ||
| 52 | func confirmName(name string) error { | ||
| 53 | fmt.Printf("Type the box name to confirm deletion of %q: ", name) | ||
| 54 | reader := bufio.NewReader(os.Stdin) | ||
| 55 | line, err := reader.ReadString('\n') | ||
| 56 | if err != nil { | ||
| 57 | return fmt.Errorf("delete %s: could not read confirmation: %w", name, err) | ||
| 58 | } | ||
| 59 | if strings.TrimSpace(line) != name { | ||
| 60 | return fmt.Errorf("delete %s: confirmation did not match, aborting", name) | ||
| 61 | } | ||
| 62 | return nil | ||
| 63 | } | ||
| @@ -0,0 +1,101 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "flag" | ||
| 6 | "fmt" | ||
| 7 | "net" | ||
| 8 | "net/http" | ||
| 9 | "os" | ||
| 10 | "time" | ||
| 11 | |||
| 12 | "github.com/davis7dotsh/tx9/internal/box" | ||
| 13 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // cmdDoctor implements `tx9 doctor <box>`: in-box `hb doctor` + host-side | ||
| 17 | // published-port probe (dossier §5). Both must pass. | ||
| 18 | func cmdDoctor(args []string) error { | ||
| 19 | fs := flag.NewFlagSet("doctor", flag.ContinueOnError) | ||
| 20 | if err := parseFlagsAnywhere(fs, args); err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 23 | name, err := requireBoxName(fs, "doctor") | ||
| 24 | if err != nil { | ||
| 25 | return err | ||
| 26 | } | ||
| 27 | |||
| 28 | return withDocker(func(ctx context.Context, cli *docker.Client) error { | ||
| 29 | b, err := box.Get(ctx, cli, name) | ||
| 30 | if err != nil { | ||
| 31 | return fmt.Errorf("doctor %s: %w", name, err) | ||
| 32 | } | ||
| 33 | // Use the aggregate state, not just AgentState: if the executor | ||
| 34 | // container is down while the agent is up, DerivedState() != "running" | ||
| 35 | // catches it here instead of burning the entire 6x3s host-probe | ||
| 36 | // loop below only to fail anyway. | ||
| 37 | if b.DerivedState() != "running" { | ||
| 38 | return fmt.Errorf("doctor %s: box is not running; run: tx9 start %s", name, name) | ||
| 39 | } | ||
| 40 | |||
| 41 | tok, err := box.Token(name) | ||
| 42 | if err != nil { | ||
| 43 | return fmt.Errorf("doctor %s: %w", name, err) | ||
| 44 | } | ||
| 45 | |||
| 46 | guestErr := box.HB(ctx, cli, b, tok, os.Stdout, os.Stderr, "doctor") | ||
| 47 | |||
| 48 | port, portErr := box.HostPort(ctx, cli, b) | ||
| 49 | var probeErr error | ||
| 50 | if portErr != nil { | ||
| 51 | probeErr = fmt.Errorf("host-side probe: %w", portErr) | ||
| 52 | } else { | ||
| 53 | probeErr = probeHostPort(port) | ||
| 54 | if probeErr == nil { | ||
| 55 | fmt.Printf("ok host executor endpoint reachable on port %s\n", port) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | if guestErr != nil || probeErr != nil { | ||
| 60 | return fmt.Errorf("doctor %s: guest=%v host=%v", name, guestErr, probeErr) | ||
| 61 | } | ||
| 62 | fmt.Printf("doctor passed for %s\n", name) | ||
| 63 | return nil | ||
| 64 | }) | ||
| 65 | } | ||
| 66 | |||
| 67 | // probeHostPort mirrors boxd's cmd_doctor host-side retry loop (dossier | ||
| 68 | // §5.2): up to 6 attempts, 3s connect timeout, 3s sleep between attempts — | ||
| 69 | // the executor daemon needs a few seconds after `start` before it's | ||
| 70 | // actually listening (dossier §10 gotcha #2). | ||
| 71 | func probeHostPort(port string) error { | ||
| 72 | const attempts = 6 | ||
| 73 | const connectTimeout = 3 * time.Second | ||
| 74 | const retryWait = 3 * time.Second | ||
| 75 | |||
| 76 | url := fmt.Sprintf("http://127.0.0.1:%s/", port) | ||
| 77 | client := &http.Client{ | ||
| 78 | Timeout: connectTimeout, | ||
| 79 | Transport: &http.Transport{ | ||
| 80 | DialContext: (&net.Dialer{Timeout: connectTimeout}).DialContext, | ||
| 81 | }, | ||
| 82 | } | ||
| 83 | |||
| 84 | var lastErr error | ||
| 85 | for attempt := 1; attempt <= attempts; attempt++ { | ||
| 86 | resp, err := client.Get(url) | ||
| 87 | if err == nil { | ||
| 88 | resp.Body.Close() | ||
| 89 | if resp.StatusCode < 400 { | ||
| 90 | return nil | ||
| 91 | } | ||
| 92 | lastErr = fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url) | ||
| 93 | } else { | ||
| 94 | lastErr = err | ||
| 95 | } | ||
| 96 | if attempt < attempts { | ||
| 97 | time.Sleep(retryWait) | ||
| 98 | } | ||
| 99 | } | ||
| 100 | return fmt.Errorf("executor dashboard unreachable on 127.0.0.1:%s after %d attempts: %w", port, attempts, lastErr) | ||
| 101 | } | ||
| @@ -0,0 +1,82 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "flag" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | "os/exec" | ||
| 9 | "syscall" | ||
| 10 | |||
| 11 | "github.com/davis7dotsh/tx9/internal/box" | ||
| 12 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // cmdEnter implements `tx9 enter <box>` (alias `ssh`): exec into the agent | ||
| 16 | // container as `agent`, dropping into the box's persistent tmux `main` | ||
| 17 | // session. Starts the box first if it isn't already running (no readiness | ||
| 18 | // retry loop here — that's `create`/`upgrade`'s job; enter just needs the | ||
| 19 | // containers up so `docker exec` has somewhere to attach). | ||
| 20 | // | ||
| 21 | // This replicates boxd's own `cmd_enter` exactly (recovered from git history | ||
| 22 | // at 6f1ced3, docker/boxd): `docker exec -it -u agent ... bash -l`. The | ||
| 23 | // login shell is what triggers guest/agent-bash-profile.sh, which runs | ||
| 24 | // `hb up`/`hb wire-once` and then execs into the tmux `main` session — there | ||
| 25 | // is no separate `hb enter` subcommand to invoke. | ||
| 26 | func cmdEnter(args []string) error { | ||
| 27 | fs := flag.NewFlagSet("enter", flag.ContinueOnError) | ||
| 28 | if err := parseFlagsAnywhere(fs, args); err != nil { | ||
| 29 | return err | ||
| 30 | } | ||
| 31 | name, err := requireBoxName(fs, "enter") | ||
| 32 | if err != nil { | ||
| 33 | return err | ||
| 34 | } | ||
| 35 | |||
| 36 | var agentID, tok string | ||
| 37 | err = withBoxLock(name, func(ctx context.Context, cli *docker.Client) error { | ||
| 38 | b, err := box.Get(ctx, cli, name) | ||
| 39 | if err != nil { | ||
| 40 | return fmt.Errorf("enter %s: %w", name, err) | ||
| 41 | } | ||
| 42 | if b.AgentState != "running" || b.ExecutorState != "running" { | ||
| 43 | if err := box.Start(ctx, cli, b); err != nil { | ||
| 44 | return fmt.Errorf("enter %s: start: %w", name, err) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | tok, err = box.Token(name) | ||
| 48 | if err != nil { | ||
| 49 | return fmt.Errorf("enter %s: %w", name, err) | ||
| 50 | } | ||
| 51 | agentID = b.AgentID | ||
| 52 | return nil | ||
| 53 | }) | ||
| 54 | if err != nil { | ||
| 55 | return err | ||
| 56 | } | ||
| 57 | |||
| 58 | dockerBin, err := exec.LookPath("docker") | ||
| 59 | if err != nil { | ||
| 60 | return fmt.Errorf("enter %s: docker binary not found on PATH: %w", name, err) | ||
| 61 | } | ||
| 62 | |||
| 63 | argv := []string{ | ||
| 64 | dockerBin, "exec", "-it", "-u", "agent", | ||
| 65 | "-e", "HOME=/data/home/agent", | ||
| 66 | "-e", "USER=agent", | ||
| 67 | "-e", "LOGNAME=agent", | ||
| 68 | "-e", "SHELL=/bin/bash", | ||
| 69 | "-e", "EXECUTOR_HOST=executor", | ||
| 70 | "-e", "BOXD_EXECUTOR_TOKEN=" + tok, | ||
| 71 | "-w", "/data/home/agent", | ||
| 72 | agentID, "bash", "-l", | ||
| 73 | } | ||
| 74 | |||
| 75 | // syscall.Exec replaces this process outright so the TTY (job control, | ||
| 76 | // signals, resize) is docker's problem from here on, exactly as boxd's | ||
| 77 | // own `exec docker exec ...` did. | ||
| 78 | if err := syscall.Exec(dockerBin, argv, os.Environ()); err != nil { | ||
| 79 | return fmt.Errorf("enter %s: exec docker: %w", name, err) | ||
| 80 | } | ||
| 81 | return nil | ||
| 82 | } | ||
| @@ -0,0 +1,195 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "flag" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | |||
| 9 | "github.com/davis7dotsh/tx9/internal/archive" | ||
| 10 | "github.com/davis7dotsh/tx9/internal/box" | ||
| 11 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 12 | "github.com/davis7dotsh/tx9/internal/names" | ||
| 13 | "github.com/davis7dotsh/tx9/internal/state" | ||
| 14 | "github.com/davis7dotsh/tx9/internal/token" | ||
| 15 | "github.com/davis7dotsh/tx9/internal/version" | ||
| 16 | ) | ||
| 17 | |||
| 18 | // restoreStageScript is boxd's stage-then-promote restore script, lifted | ||
| 19 | // verbatim (dossier §7.2), run inside a throwaway container that mounts | ||
| 20 | // the freshly-created (but not-yet-started) box's agent-data volume | ||
| 21 | // directly at /data. It wipes /data, extracts into a staging subdirectory, | ||
| 22 | // sanity-checks the tree, fixes ownership, re-arms the quiesce/gateway | ||
| 23 | // markers (dossier §7.3 — a restored box must always arrive quiesced and | ||
| 24 | // gateway-disabled regardless of the source box's state when it was | ||
| 25 | // saved), runs a last-chance hermes-state integrity check against the | ||
| 26 | // staged (not yet live) Hermes home, and only then promotes the staged | ||
| 27 | // tree up into /data itself. | ||
| 28 | const restoreStageScript = `set -euo pipefail | ||
| 29 | find /data -mindepth 1 -delete | ||
| 30 | stage=/data/.hb-restore-stage | ||
| 31 | mkdir -m 0700 "$stage" | ||
| 32 | tar xzf - --no-same-owner -C "$stage" | ||
| 33 | test -d "$stage/home/agent" | ||
| 34 | chown root:root "$stage" "$stage/home" | ||
| 35 | chmod 0711 "$stage" "$stage/home" | ||
| 36 | chown -R agent:agent "$stage/home/agent" "$stage/logs" 2>/dev/null || true | ||
| 37 | mkdir -p "$stage/home/agent/.config/hermes-box" | ||
| 38 | touch "$stage/home/agent/.config/hermes-box/quiesced" \ | ||
| 39 | "$stage/home/agent/.config/hermes-box/gateway-disabled" \ | ||
| 40 | "$stage/home/agent/.config/hermes-box/gateway-policy-initialized" | ||
| 41 | chown -R agent:agent "$stage/home/agent/.config/hermes-box" | ||
| 42 | runuser -u agent -- env HOME=/data/home/agent \ | ||
| 43 | /opt/hermes-box/bin/hermes-state verify --home "$stage/home/agent/.hermes" >/dev/null | ||
| 44 | shopt -s dotglob | ||
| 45 | mv "$stage"/* /data/ | ||
| 46 | rmdir "$stage" | ||
| 47 | chown root:root /data && chmod 0711 /data | ||
| 48 | ` | ||
| 49 | |||
| 50 | // cmdImport implements `tx9 import <file.tx9>`, the Go port of boxd's | ||
| 51 | // cmd_load (dossier §7): read metadata (plaintext, before touching the | ||
| 52 | // possibly-encrypted payload) -> resolve target name -> hard fail on | ||
| 53 | // collision -> decrypt+validate BEFORE creating anything -> mint a fresh | ||
| 54 | // token -> create network/volumes/containers without starting them -> | ||
| 55 | // stage-then-promote restore via a throwaway container -> start the box -> | ||
| 56 | // verify-state (not the full runtime-prep gate, since that would try to | ||
| 57 | // wire MCP against a box the restore deliberately leaves quiesced). | ||
| 58 | func cmdImport(args []string) error { | ||
| 59 | fs := flag.NewFlagSet("import", flag.ContinueOnError) | ||
| 60 | nameOverride := fs.String("name", "", "box name to restore under (default: from archive metadata)") | ||
| 61 | password := fs.String("password", "", "archive passphrase (else TX9_PASSWORD env, else prompt)") | ||
| 62 | if err := parseFlagsAnywhere(fs, args); err != nil { | ||
| 63 | return err | ||
| 64 | } | ||
| 65 | if fs.NArg() < 1 { | ||
| 66 | return fmt.Errorf("import: archive file required (usage: tx9 import <file.tx9>)") | ||
| 67 | } | ||
| 68 | archivePath := fs.Arg(0) | ||
| 69 | |||
| 70 | meta, err := archive.ReadMetadata(archivePath) | ||
| 71 | if err != nil { | ||
| 72 | return fmt.Errorf("import %s: %w", archivePath, err) | ||
| 73 | } | ||
| 74 | |||
| 75 | name := *nameOverride | ||
| 76 | if name == "" { | ||
| 77 | name = meta.BoxName | ||
| 78 | } | ||
| 79 | if err := names.Validate(name); err != nil { | ||
| 80 | return fmt.Errorf("import %s: %w", archivePath, err) | ||
| 81 | } | ||
| 82 | |||
| 83 | var passphrase string | ||
| 84 | if meta.Encrypted { | ||
| 85 | passphrase, err = resolvePassword(*password, false) | ||
| 86 | if err != nil { | ||
| 87 | return fmt.Errorf("import %s: %w", archivePath, err) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error { | ||
| 92 | if exists, err := box.Exists(ctx, cli, name); err != nil { | ||
| 93 | return fmt.Errorf("import %s: %w", name, err) | ||
| 94 | } else if exists { | ||
| 95 | return fmt.Errorf("import %s: box %q already exists, choose a different --name or delete it first", name, name) | ||
| 96 | } | ||
| 97 | |||
| 98 | tmpDir, err := os.MkdirTemp("", "tx9-import-*") | ||
| 99 | if err != nil { | ||
| 100 | return fmt.Errorf("import %s: %w", name, err) | ||
| 101 | } | ||
| 102 | defer os.RemoveAll(tmpDir) | ||
| 103 | |||
| 104 | fmt.Println("tx9: extracting archive payload") | ||
| 105 | rawPath := tmpDir + "/data.payload" | ||
| 106 | if _, err := archive.ExtractData(archivePath, rawPath); err != nil { | ||
| 107 | return fmt.Errorf("import %s: %w", name, err) | ||
| 108 | } | ||
| 109 | |||
| 110 | dataTarGzPath := rawPath | ||
| 111 | if meta.Encrypted { | ||
| 112 | fmt.Println("tx9: decrypting archive") | ||
| 113 | decPath := tmpDir + "/data.tar.gz" | ||
| 114 | if err := archive.Decrypt(rawPath, decPath, passphrase); err != nil { | ||
| 115 | return fmt.Errorf("import %s: %w", name, err) | ||
| 116 | } | ||
| 117 | dataTarGzPath = decPath | ||
| 118 | } | ||
| 119 | |||
| 120 | fmt.Println("tx9: validating archive contents") | ||
| 121 | if err := validateFileAsDataTar(dataTarGzPath); err != nil { | ||
| 122 | return fmt.Errorf("import %s: archive failed validation, refusing to restore: %w", name, err) | ||
| 123 | } | ||
| 124 | |||
| 125 | tok, err := token.Mint() | ||
| 126 | if err != nil { | ||
| 127 | return fmt.Errorf("import %s: %w", name, err) | ||
| 128 | } | ||
| 129 | if err := state.WriteBoxEnv(name, map[string]string{"EXECUTOR_MCP_TOKEN": tok}); err != nil { | ||
| 130 | return fmt.Errorf("import %s: %w", name, err) | ||
| 131 | } | ||
| 132 | |||
| 133 | imageTag := fmt.Sprintf("tx9-box:%s", version.Version) | ||
| 134 | if err := ensureBoxImage(ctx, cli, imageTag); err != nil { | ||
| 135 | return fmt.Errorf("import %s: %w", name, err) | ||
| 136 | } | ||
| 137 | |||
| 138 | fmt.Printf("tx9: creating box %q (network, volumes, containers — not started yet)\n", name) | ||
| 139 | b, err := box.Create(ctx, cli, box.CreateOpts{Name: name, Image: imageTag, Token: tok, NoStart: true}) | ||
| 140 | if err != nil { | ||
| 141 | box.Destroy(ctx, cli, name) | ||
| 142 | return fmt.Errorf("import %s: %w", name, err) | ||
| 143 | } | ||
| 144 | |||
| 145 | fmt.Println("tx9: restoring data into the agent volume (stage-then-promote)") | ||
| 146 | agentVol, _ := box.VolumeNames(name) | ||
| 147 | sf, err := os.Open(dataTarGzPath) | ||
| 148 | if err != nil { | ||
| 149 | box.Destroy(ctx, cli, name) | ||
| 150 | return fmt.Errorf("import %s: %w", name, err) | ||
| 151 | } | ||
| 152 | exitCode, err := cli.RunEphemeral(ctx, docker.EphemeralOpts{ | ||
| 153 | Image: imageTag, | ||
| 154 | Entrypoint: []string{"bash"}, | ||
| 155 | Cmd: []string{"-c", restoreStageScript}, | ||
| 156 | Binds: []string{agentVol + ":/data"}, | ||
| 157 | Stdin: sf, | ||
| 158 | Stdout: os.Stdout, | ||
| 159 | Stderr: os.Stderr, | ||
| 160 | }) | ||
| 161 | sf.Close() | ||
| 162 | if err != nil { | ||
| 163 | box.Destroy(ctx, cli, name) | ||
| 164 | return fmt.Errorf("import %s: stage-then-promote restore: %w", name, err) | ||
| 165 | } | ||
| 166 | if exitCode != 0 { | ||
| 167 | box.Destroy(ctx, cli, name) | ||
| 168 | return fmt.Errorf("import %s: stage-then-promote restore exited %d", name, exitCode) | ||
| 169 | } | ||
| 170 | |||
| 171 | fmt.Println("tx9: starting box") | ||
| 172 | if err := box.Start(ctx, cli, b); err != nil { | ||
| 173 | box.Destroy(ctx, cli, name) | ||
| 174 | return fmt.Errorf("import %s: %w", name, err) | ||
| 175 | } | ||
| 176 | |||
| 177 | // Deliberately NOT box.PrepareRuntime: it unconditionally runs | ||
| 178 | // `hb wire-once`, which would try to wire MCP against a box the | ||
| 179 | // restore just arranged to arrive quiesced + gateway-disabled | ||
| 180 | // (dossier §7.3). verify-state confirms the container/daemon | ||
| 181 | // stack is healthy without touching that contract; a failure here | ||
| 182 | // is a warning, not a reason to tear the box back down, since the | ||
| 183 | // restore itself already succeeded. | ||
| 184 | if err := box.HB(ctx, cli, b, tok, os.Stdout, os.Stderr, "verify-state"); err != nil { | ||
| 185 | fmt.Fprintf(os.Stderr, "tx9: warning: post-import verify-state reported a problem, inspect the box before trusting it: %v\n", err) | ||
| 186 | } | ||
| 187 | |||
| 188 | fmt.Printf("\ntx9: box %q restored from %s.\n", name, archivePath) | ||
| 189 | fmt.Println("It has arrived quiesced and with the Hermes gateway disabled (single-writer safety gate). Next steps:") | ||
| 190 | fmt.Printf(" 1. tx9 enter %s\n", name) | ||
| 191 | fmt.Println(" 2. hb resume") | ||
| 192 | fmt.Println(" 3. hb gateway-enable --confirm-single-writer I_CONFIRM_NO_OTHER_GATEWAY_USES_THIS_IDENTITY") | ||
| 193 | return nil | ||
| 194 | }) | ||
| 195 | } | ||
| @@ -0,0 +1,63 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "flag" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | "text/tabwriter" | ||
| 9 | |||
| 10 | "github.com/davis7dotsh/tx9/internal/box" | ||
| 11 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 12 | "github.com/davis7dotsh/tx9/internal/version" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // cmdList implements `tx9 list` (command surface: all boxes on this | ||
| 16 | // machine from daemon labels — state, image version vs CLI version drift, | ||
| 17 | // dashboard URL). | ||
| 18 | func cmdList(args []string) error { | ||
| 19 | fs := flag.NewFlagSet("list", flag.ContinueOnError) | ||
| 20 | if err := parseFlagsAnywhere(fs, args); err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 23 | |||
| 24 | return withDocker(func(ctx context.Context, cli *docker.Client) error { | ||
| 25 | boxes, err := box.List(ctx, cli) | ||
| 26 | if err != nil { | ||
| 27 | return fmt.Errorf("list: %w", err) | ||
| 28 | } | ||
| 29 | if len(boxes) == 0 { | ||
| 30 | fmt.Println("no boxes (tx9 create to make one)") | ||
| 31 | return nil | ||
| 32 | } | ||
| 33 | |||
| 34 | w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) | ||
| 35 | fmt.Fprintln(w, "NAME\tSTATE\tIMAGE VERSION\tURL") | ||
| 36 | for _, b := range boxes { | ||
| 37 | state := b.DerivedState() | ||
| 38 | |||
| 39 | url := "-" | ||
| 40 | if state == "running" { | ||
| 41 | if port, err := box.HostPort(ctx, cli, &b); err == nil { | ||
| 42 | url = box.DashboardURL(port) | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", b.Name, state, imageVersionDisplay(b.Version), url) | ||
| 47 | } | ||
| 48 | return w.Flush() | ||
| 49 | }) | ||
| 50 | } | ||
| 51 | |||
| 52 | // imageVersionDisplay formats a box's tx9.version label against the | ||
| 53 | // running CLI's own version, flagging drift the way `tx9 upgrade <box>` | ||
| 54 | // would resolve (spec: "0.1.0 (cli: 0.2.0)" when they differ). | ||
| 55 | func imageVersionDisplay(boxVersion string) string { | ||
| 56 | if boxVersion == "" { | ||
| 57 | return "?" | ||
| 58 | } | ||
| 59 | if boxVersion == version.Version { | ||
| 60 | return boxVersion | ||
| 61 | } | ||
| 62 | return fmt.Sprintf("%s (cli: %s)", boxVersion, version.Version) | ||
| 63 | } | ||
| @@ -0,0 +1,31 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | |||
| 6 | "github.com/davis7dotsh/tx9/internal/version" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func TestImageVersionDisplay(t *testing.T) { | ||
| 10 | cases := []struct { | ||
| 11 | name string | ||
| 12 | boxVersion string | ||
| 13 | want string | ||
| 14 | }{ | ||
| 15 | {name: "empty label", boxVersion: "", want: "?"}, | ||
| 16 | {name: "matches CLI version", boxVersion: version.Version, want: version.Version}, | ||
| 17 | { | ||
| 18 | name: "drifted from CLI version", | ||
| 19 | boxVersion: "0.1.0-not-the-cli-version", | ||
| 20 | want: "0.1.0-not-the-cli-version (cli: " + version.Version + ")", | ||
| 21 | }, | ||
| 22 | } | ||
| 23 | |||
| 24 | for _, tc := range cases { | ||
| 25 | t.Run(tc.name, func(t *testing.T) { | ||
| 26 | if got := imageVersionDisplay(tc.boxVersion); got != tc.want { | ||
| 27 | t.Errorf("imageVersionDisplay(%q) = %q, want %q", tc.boxVersion, got, tc.want) | ||
| 28 | } | ||
| 29 | }) | ||
| 30 | } | ||
| 31 | } | ||
| @@ -0,0 +1,70 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "flag" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | "os/exec" | ||
| 9 | |||
| 10 | "github.com/davis7dotsh/tx9/internal/box" | ||
| 11 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // cmdOpen implements `tx9 open <box>`: print (or open) the authenticated | ||
| 15 | // dashboard URL (?_token=). Mirrors boxd's cmd_open (dossier §4): the | ||
| 16 | // token-bearing URL is a one-shot secret, distinct from `create`'s printed | ||
| 17 | // (unauthenticated) dashboard URL. | ||
| 18 | func cmdOpen(args []string) error { | ||
| 19 | fs := flag.NewFlagSet("open", flag.ContinueOnError) | ||
| 20 | if err := parseFlagsAnywhere(fs, args); err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 23 | name, err := requireBoxName(fs, "open") | ||
| 24 | if err != nil { | ||
| 25 | return err | ||
| 26 | } | ||
| 27 | |||
| 28 | return withDocker(func(ctx context.Context, cli *docker.Client) error { | ||
| 29 | b, err := box.Get(ctx, cli, name) | ||
| 30 | if err != nil { | ||
| 31 | return fmt.Errorf("open %s: %w", name, err) | ||
| 32 | } | ||
| 33 | if b.DerivedState() != "running" { | ||
| 34 | return fmt.Errorf("open %s: box is not running; run: tx9 start %s", name, name) | ||
| 35 | } | ||
| 36 | |||
| 37 | port, err := box.HostPort(ctx, cli, b) | ||
| 38 | if err != nil { | ||
| 39 | return fmt.Errorf("open %s: %w", name, err) | ||
| 40 | } | ||
| 41 | tok, err := box.Token(name) | ||
| 42 | if err != nil { | ||
| 43 | return fmt.Errorf("open %s: %w", name, err) | ||
| 44 | } | ||
| 45 | |||
| 46 | url := box.OpenURL(port, tok) | ||
| 47 | |||
| 48 | if browser := firstNonEmpty(os.Getenv("TX9_BROWSER"), os.Getenv("BOX_BROWSER")); browser != "" { | ||
| 49 | if _, lookErr := exec.LookPath(browser); lookErr == nil { | ||
| 50 | if runErr := exec.Command(browser, url).Start(); runErr == nil { | ||
| 51 | fmt.Printf("tx9: opened dashboard for %s\n", name) | ||
| 52 | return nil | ||
| 53 | } | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | fmt.Println("tx9: dashboard URL — this embeds a bearer token, treat it as a secret:") | ||
| 58 | fmt.Println(url) | ||
| 59 | return nil | ||
| 60 | }) | ||
| 61 | } | ||
| 62 | |||
| 63 | func firstNonEmpty(vals ...string) string { | ||
| 64 | for _, v := range vals { | ||
| 65 | if v != "" { | ||
| 66 | return v | ||
| 67 | } | ||
| 68 | } | ||
| 69 | return "" | ||
| 70 | } | ||
| @@ -0,0 +1,168 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "flag" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "strings" | ||
| 10 | |||
| 11 | "github.com/davis7dotsh/tx9/internal/box" | ||
| 12 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 13 | "github.com/davis7dotsh/tx9/internal/lock" | ||
| 14 | "github.com/davis7dotsh/tx9/internal/state" | ||
| 15 | "github.com/davis7dotsh/tx9/internal/version" | ||
| 16 | ) | ||
| 17 | |||
| 18 | // cmdPrune implements `tx9 prune`: garbage-collect stale tx9-box:* images | ||
| 19 | // (superseded versions no container references) and orphaned per-box state | ||
| 20 | // files (~/.tx9/boxes/*.env with no matching box left in the daemon). | ||
| 21 | // Unlike other lifecycle commands, prune spans every box at once, so it | ||
| 22 | // doesn't take a per-box lock — it only ever removes things nothing is | ||
| 23 | // using. | ||
| 24 | func cmdPrune(args []string) error { | ||
| 25 | fs := flag.NewFlagSet("prune", flag.ContinueOnError) | ||
| 26 | if err := parseFlagsAnywhere(fs, args); err != nil { | ||
| 27 | return err | ||
| 28 | } | ||
| 29 | |||
| 30 | return withDocker(func(ctx context.Context, cli *docker.Client) error { | ||
| 31 | removedImages, err := pruneImages(ctx, cli) | ||
| 32 | if err != nil { | ||
| 33 | return fmt.Errorf("prune: %w", err) | ||
| 34 | } | ||
| 35 | removedEnvFiles, err := pruneStateFiles(ctx, cli) | ||
| 36 | if err != nil { | ||
| 37 | return fmt.Errorf("prune: %w", err) | ||
| 38 | } | ||
| 39 | |||
| 40 | if len(removedImages) == 0 && len(removedEnvFiles) == 0 { | ||
| 41 | fmt.Println("tx9: nothing to prune") | ||
| 42 | return nil | ||
| 43 | } | ||
| 44 | for _, tag := range removedImages { | ||
| 45 | fmt.Printf("tx9: removed unused image %s\n", tag) | ||
| 46 | } | ||
| 47 | for _, path := range removedEnvFiles { | ||
| 48 | fmt.Printf("tx9: removed stale state file %s\n", path) | ||
| 49 | } | ||
| 50 | return nil | ||
| 51 | }) | ||
| 52 | } | ||
| 53 | |||
| 54 | // pruneImages removes tx9-box:* images whose version tag isn't the | ||
| 55 | // running CLI's own version AND aren't referenced by any container tx9 | ||
| 56 | // still knows about (running or stopped — a stopped box still "uses" its | ||
| 57 | // image). The current version's image is always kept even if unused, | ||
| 58 | // since it's what the next `create`/`upgrade` needs. | ||
| 59 | func pruneImages(ctx context.Context, cli *docker.Client) ([]string, error) { | ||
| 60 | images, err := cli.ImageList(ctx, "tx9-box:*") | ||
| 61 | if err != nil { | ||
| 62 | return nil, err | ||
| 63 | } | ||
| 64 | |||
| 65 | containers, err := cli.ListBoxContainers(ctx) | ||
| 66 | if err != nil { | ||
| 67 | return nil, err | ||
| 68 | } | ||
| 69 | inUse := map[string]bool{} | ||
| 70 | for _, c := range containers { | ||
| 71 | inUse[c.Image] = true | ||
| 72 | inUse[c.ImageID] = true | ||
| 73 | } | ||
| 74 | |||
| 75 | currentTag := fmt.Sprintf("tx9-box:%s", version.Version) | ||
| 76 | |||
| 77 | var removed []string | ||
| 78 | for _, img := range images { | ||
| 79 | if inUse[img.ID] { | ||
| 80 | continue | ||
| 81 | } | ||
| 82 | |||
| 83 | keep := false | ||
| 84 | usedByTag := false | ||
| 85 | for _, tag := range img.RepoTags { | ||
| 86 | if tag == currentTag { | ||
| 87 | keep = true | ||
| 88 | } | ||
| 89 | if inUse[tag] { | ||
| 90 | usedByTag = true | ||
| 91 | } | ||
| 92 | } | ||
| 93 | if keep || usedByTag { | ||
| 94 | continue | ||
| 95 | } | ||
| 96 | |||
| 97 | if err := cli.ImageRemove(ctx, img.ID); err != nil { | ||
| 98 | return removed, err | ||
| 99 | } | ||
| 100 | if len(img.RepoTags) > 0 { | ||
| 101 | removed = append(removed, img.RepoTags[0]) | ||
| 102 | } else { | ||
| 103 | removed = append(removed, img.ID) | ||
| 104 | } | ||
| 105 | } | ||
| 106 | return removed, nil | ||
| 107 | } | ||
| 108 | |||
| 109 | // pruneStateFiles removes ~/.tx9/boxes/<name>.env files with no | ||
| 110 | // corresponding box left in the daemon — leftovers from a box deleted by | ||
| 111 | // some other means (e.g. `docker rm` directly) that skipped tx9's own | ||
| 112 | // cleanup. | ||
| 113 | func pruneStateFiles(ctx context.Context, cli *docker.Client) ([]string, error) { | ||
| 114 | boxes, err := box.List(ctx, cli) | ||
| 115 | if err != nil { | ||
| 116 | return nil, err | ||
| 117 | } | ||
| 118 | known := make(map[string]bool, len(boxes)) | ||
| 119 | for _, b := range boxes { | ||
| 120 | known[b.Name] = true | ||
| 121 | } | ||
| 122 | |||
| 123 | dir, err := state.BoxesDir() | ||
| 124 | if err != nil { | ||
| 125 | return nil, err | ||
| 126 | } | ||
| 127 | entries, err := os.ReadDir(dir) | ||
| 128 | if err != nil { | ||
| 129 | return nil, fmt.Errorf("read %s: %w", dir, err) | ||
| 130 | } | ||
| 131 | |||
| 132 | var removed []string | ||
| 133 | for _, e := range entries { | ||
| 134 | if e.IsDir() || !strings.HasSuffix(e.Name(), ".env") { | ||
| 135 | continue | ||
| 136 | } | ||
| 137 | name := strings.TrimSuffix(e.Name(), ".env") | ||
| 138 | if known[name] { | ||
| 139 | continue | ||
| 140 | } | ||
| 141 | |||
| 142 | // An env file is written by create/import before either of them | ||
| 143 | // takes this box's per-box lock and holds it for their entire | ||
| 144 | // run, so a box with no containers yet (known[name] == false) | ||
| 145 | // might just be mid-create/mid-import rather than truly | ||
| 146 | // orphaned. Try the same non-blocking lock they use; if it's | ||
| 147 | // held, skip this file instead of deleting state out from under | ||
| 148 | // a running operation. | ||
| 149 | lockPath, err := state.LockPath(name) | ||
| 150 | if err != nil { | ||
| 151 | return removed, err | ||
| 152 | } | ||
| 153 | release, err := lock.Acquire(lockPath) | ||
| 154 | if err != nil { | ||
| 155 | fmt.Printf("tx9: skipping %s: operation in progress\n", name) | ||
| 156 | continue | ||
| 157 | } | ||
| 158 | |||
| 159 | path := filepath.Join(dir, e.Name()) | ||
| 160 | rmErr := os.Remove(path) | ||
| 161 | release() | ||
| 162 | if rmErr != nil { | ||
| 163 | return removed, fmt.Errorf("remove %s: %w", path, rmErr) | ||
| 164 | } | ||
| 165 | removed = append(removed, path) | ||
| 166 | } | ||
| 167 | return removed, nil | ||
| 168 | } | ||
| @@ -0,0 +1,60 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "flag" | ||
| 6 | "fmt" | ||
| 7 | |||
| 8 | "github.com/davis7dotsh/tx9/internal/box" | ||
| 9 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // cmdStart implements `tx9 start <box>`: start both containers together. | ||
| 13 | // Volumes persist. | ||
| 14 | func cmdStart(args []string) error { | ||
| 15 | fs := flag.NewFlagSet("start", flag.ContinueOnError) | ||
| 16 | if err := parseFlagsAnywhere(fs, args); err != nil { | ||
| 17 | return err | ||
| 18 | } | ||
| 19 | name, err := requireBoxName(fs, "start") | ||
| 20 | if err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 23 | |||
| 24 | return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error { | ||
| 25 | b, err := box.Get(ctx, cli, name) | ||
| 26 | if err != nil { | ||
| 27 | return fmt.Errorf("start %s: %w", name, err) | ||
| 28 | } | ||
| 29 | if err := box.Start(ctx, cli, b); err != nil { | ||
| 30 | return fmt.Errorf("start %s: %w", name, err) | ||
| 31 | } | ||
| 32 | fmt.Printf("tx9: box %s started\n", name) | ||
| 33 | return nil | ||
| 34 | }) | ||
| 35 | } | ||
| 36 | |||
| 37 | // cmdStop implements `tx9 stop <box>`: stop both containers together. | ||
| 38 | // Volumes persist. | ||
| 39 | func cmdStop(args []string) error { | ||
| 40 | fs := flag.NewFlagSet("stop", flag.ContinueOnError) | ||
| 41 | if err := parseFlagsAnywhere(fs, args); err != nil { | ||
| 42 | return err | ||
| 43 | } | ||
| 44 | name, err := requireBoxName(fs, "stop") | ||
| 45 | if err != nil { | ||
| 46 | return err | ||
| 47 | } | ||
| 48 | |||
| 49 | return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error { | ||
| 50 | b, err := box.Get(ctx, cli, name) | ||
| 51 | if err != nil { | ||
| 52 | return fmt.Errorf("stop %s: %w", name, err) | ||
| 53 | } | ||
| 54 | if err := box.Stop(ctx, cli, b); err != nil { | ||
| 55 | return fmt.Errorf("stop %s: %w", name, err) | ||
| 56 | } | ||
| 57 | fmt.Printf("tx9: box %s stopped\n", name) | ||
| 58 | return nil | ||
| 59 | }) | ||
| 60 | } | ||
| @@ -0,0 +1,137 @@ | |||
| 1 | package cli | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "flag" | ||
| 7 | "fmt" | ||
| 8 | "os" | ||
| 9 | |||
| 10 | "github.com/davis7dotsh/tx9/internal/box" | ||
| 11 | "github.com/davis7dotsh/tx9/internal/docker" | ||
| 12 | "github.com/davis7dotsh/tx9/internal/selfupdate" | ||
| 13 | "github.com/davis7dotsh/tx9/internal/version" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // cmdUpgrade implements `tx9 upgrade [box]`. | ||
| 17 | // | ||
| 18 | // With a box argument: stop and remove both of the box's containers | ||
| 19 | // (network, volumes, and the minted token are left untouched), make sure | ||
| 20 | // the current CLI's image is built, then recreate both containers on that | ||
| 21 | // image via box.Create (which is idempotent against the pre-existing | ||
| 22 | // network/volumes) and run the same readiness gate `create` uses. | ||
| 23 | // | ||
| 24 | // With no argument: self-update the tx9 binary itself (internal/selfupdate, | ||
| 25 | // tx9-cli-design.md decision 11) — query GitHub Releases, download + | ||
| 26 | // checksum-verify the matching asset, and atomically replace the running | ||
| 27 | // executable. | ||
| 28 | func cmdUpgrade(args []string) error { | ||
| 29 | fs := flag.NewFlagSet("upgrade", flag.ContinueOnError) | ||
| 30 | force := fs.Bool("force", false, "self-update: skip the dev-build guard and reinstall even if already current") | ||
| 31 | if err := parseFlagsAnywhere(fs, args); err != nil { | ||
| 32 | return err | ||
| 33 | } | ||
| 34 | |||
| 35 | if fs.NArg() == 0 { | ||
| 36 | return cmdUpgradeSelf(*force) | ||
| 37 | } | ||
| 38 | name := fs.Arg(0) | ||
| 39 | |||
| 40 | return withBoxLock(name, func(ctx context.Context, cli *docker.Client) error { | ||
| 41 | b, err := box.Get(ctx, cli, name) | ||
| 42 | if err != nil { | ||
| 43 | return fmt.Errorf("upgrade %s: %w", name, err) | ||
| 44 | } | ||
| 45 | fromVersion := b.Version | ||
| 46 | |||
| 47 | imageTag := fmt.Sprintf("tx9-box:%s", version.Version) | ||
| 48 | if err := ensureBoxImage(ctx, cli, imageTag); err != nil { | ||
| 49 | return fmt.Errorf("upgrade %s: %w", name, err) | ||
| 50 | } | ||
| 51 | |||
| 52 | tok, err := box.Token(name) | ||
| 53 | if err != nil { | ||
| 54 | return fmt.Errorf("upgrade %s: %w", name, err) | ||
| 55 | } | ||
| 56 | |||
| 57 | fmt.Printf("tx9: stopping and removing %s's containers (network/volumes/token kept)\n", name) | ||
| 58 | if err := removeBoxContainers(ctx, cli, b); err != nil { | ||
| 59 | return fmt.Errorf("upgrade %s: %w", name, err) | ||
| 60 | } | ||
| 61 | |||
| 62 | fmt.Printf("tx9: recreating %s on %s\n", name, imageTag) | ||
| 63 | newB, err := box.Create(ctx, cli, box.CreateOpts{Name: name, Image: imageTag, Token: tok}) | ||
| 64 | if err != nil { | ||
| 65 | return fmt.Errorf("upgrade %s: containers not recreated, box left without them: %w", name, err) | ||
| 66 | } | ||
| 67 | |||
| 68 | fmt.Println("tx9: preparing runtime (executor reachability, MCP wiring, doctor)") | ||
| 69 | if err := box.PrepareRuntime(ctx, cli, newB, tok, os.Stdout); err != nil { | ||
| 70 | return fmt.Errorf("upgrade %s: runtime failed: %w", name, err) | ||
| 71 | } | ||
| 72 | |||
| 73 | if fromVersion == version.Version { | ||
| 74 | fmt.Printf("tx9: box %s recreated (already on %s; containers reset)\n", name, version.Version) | ||
| 75 | } else { | ||
| 76 | fmt.Printf("tx9: box %s upgraded %s -> %s\n", name, displayVersion(fromVersion), version.Version) | ||
| 77 | } | ||
| 78 | return nil | ||
| 79 | }) | ||
| 80 | } | ||
| 81 | |||
| 82 | // cmdUpgradeSelf implements `tx9 upgrade` with no box argument: self-update | ||
| 83 | // the running binary via internal/selfupdate. ErrNoRelease and | ||
| 84 | // ErrDevVersion get their own actionable messages since they're the | ||
| 85 | // expected/common cases (no releases published yet; a `go run`/unversioned | ||
| 86 | // build); anything else from Update is returned as-is, already wrapped | ||
| 87 | // with context by that package. | ||
| 88 | func cmdUpgradeSelf(force bool) error { | ||
| 89 | res, err := selfupdate.Update(selfupdate.Options{ | ||
| 90 | CurrentVersion: version.Version, | ||
| 91 | Force: force, | ||
| 92 | Out: os.Stdout, | ||
| 93 | }) | ||
| 94 | if err != nil { | ||
| 95 | if errors.Is(err, selfupdate.ErrNoRelease) { | ||
| 96 | return fmt.Errorf("upgrade: no releases published yet for %s (build from source, or check back after the first release)", selfupdate.Repo) | ||
| 97 | } | ||
| 98 | // ErrDevVersion and everything else already read fine wrapped | ||
| 99 | // plainly; selfupdate's own errors carry their own context. | ||
| 100 | return fmt.Errorf("upgrade: %w", err) | ||
| 101 | } | ||
| 102 | |||
| 103 | if !res.Applied { | ||
| 104 | fmt.Printf("tx9: already up to date (%s)\n", displayVersion(version.Version)) | ||
| 105 | return nil | ||
| 106 | } | ||
| 107 | fmt.Printf("tx9: upgraded %s -> %s\n", displayVersion(version.Version), res.ToVersion) | ||
| 108 | return nil | ||
| 109 | } | ||
| 110 | |||
| 111 | // removeBoxContainers stops (best-effort) then force-removes a box's | ||
| 112 | // agent/executor containers, leaving its network, volumes, and token file | ||
| 113 | // alone — the part of box.Destroy this needs, without the rest of the | ||
| 114 | // teardown. | ||
| 115 | func removeBoxContainers(ctx context.Context, cli *docker.Client, b *box.Box) error { | ||
| 116 | var errs []error | ||
| 117 | if b.AgentID != "" { | ||
| 118 | _ = cli.ContainerStop(ctx, b.AgentID, nil) | ||
| 119 | if err := cli.ContainerRemove(ctx, b.AgentID, true); err != nil { | ||
| 120 | errs = append(errs, fmt.Errorf("remove agent: %w", err)) | ||
| 121 | } | ||
| 122 | } | ||
| 123 | if b.ExecutorID != "" { | ||
| 124 | _ = cli.ContainerStop(ctx, b.ExecutorID, nil) | ||
| 125 | if err := cli.ContainerRemove(ctx, b.ExecutorID, true); err != nil { | ||
| 126 | errs = append(errs, fmt.Errorf("remove executor: %w", err)) | ||
| 127 | } | ||
| 128 | } | ||
| 129 | return errors.Join(errs...) | ||
| 130 | } | ||
| 131 | |||
| 132 | func displayVersion(v string) string { | ||
| 133 | if v == "" { | ||
| 134 | return "?" | ||
| 135 | } | ||
| 136 | return v | ||
| 137 | } | ||
| @@ -0,0 +1,137 @@ | |||
| 1 | // Package cli is tx9's command dispatcher: it parses the subcommand off | ||
| 2 | // os.Args and routes to the per-command implementation in its own | ||
| 3 | // cmd_*.go file. Every command is registered here so later work on | ||
| 4 | // individual commands never needs to touch this file. | ||
| 5 | package cli | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "fmt" | ||
| 9 | "io/fs" | ||
| 10 | "os" | ||
| 11 | |||
| 12 | "github.com/davis7dotsh/tx9/internal/version" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // BuildContext is the embedded Docker build-context filesystem (provision/, | ||
| 16 | // guest/, docker/, box.env). It is set by Run from the root-level embed.FS | ||
| 17 | // in /assets.go — package main can't be imported by internal packages (see | ||
| 18 | // that file's doc comment for why), so it's threaded through here instead. | ||
| 19 | // Commands that build or rebuild the box image (create, upgrade) read it. | ||
| 20 | var BuildContext fs.FS | ||
| 21 | |||
| 22 | // commandFunc is the signature every subcommand implements. Each owns its | ||
| 23 | // own flag.FlagSet; args excludes the program name and the subcommand | ||
| 24 | // itself (i.e. it's exactly what flag.FlagSet.Parse expects). | ||
| 25 | type commandFunc func(args []string) error | ||
| 26 | |||
| 27 | // commands maps canonical subcommand names to their implementation. | ||
| 28 | // Registered in registerCommands below. | ||
| 29 | var commands = map[string]commandFunc{} | ||
| 30 | |||
| 31 | // commandOrder lists canonical subcommand names in the order they should | ||
| 32 | // appear in help output (matches the command surface table in | ||
| 33 | // docs/tx9-cli-design.md). | ||
| 34 | var commandOrder = []string{ | ||
| 35 | "create", "list", "enter", "start", "stop", "backup", "import", | ||
| 36 | "open", "doctor", "upgrade", "delete", "prune", | ||
| 37 | } | ||
| 38 | |||
| 39 | // commandHelp is a one-line description per canonical command, for help | ||
| 40 | // output (matches docs/tx9-cli-design.md's command surface table). | ||
| 41 | var commandHelp = map[string]string{ | ||
| 42 | "create": "generate/build if needed, create and start a new box", | ||
| 43 | "list": "list boxes on this machine (state, image version, dashboard URL)", | ||
| 44 | "enter": "exec into a box's agent container (alias: ssh)", | ||
| 45 | "start": "start a stopped box", | ||
| 46 | "stop": "stop a running box", | ||
| 47 | "backup": "archive a box to a .tx9 file (alias: export)", | ||
| 48 | "import": "restore a box from a .tx9 file", | ||
| 49 | "open": "print/open a box's authenticated dashboard URL", | ||
| 50 | "doctor": "run health checks against a box", | ||
| 51 | "upgrade": "self-update the CLI, or move a box onto the current image", | ||
| 52 | "delete": "delete a box's containers, volumes, and state", | ||
| 53 | "prune": "remove unused tx9-box images and stale state", | ||
| 54 | } | ||
| 55 | |||
| 56 | // aliases maps alternate spellings to their canonical command name | ||
| 57 | // (decision 4: enter/ssh and backup/export are aliases). | ||
| 58 | var aliases = map[string]string{ | ||
| 59 | "ssh": "enter", | ||
| 60 | "export": "backup", | ||
| 61 | } | ||
| 62 | |||
| 63 | func registerCommands() { | ||
| 64 | commands["create"] = cmdCreate | ||
| 65 | commands["list"] = cmdList | ||
| 66 | commands["enter"] = cmdEnter | ||
| 67 | commands["start"] = cmdStart | ||
| 68 | commands["stop"] = cmdStop | ||
| 69 | commands["backup"] = cmdBackup | ||
| 70 | commands["import"] = cmdImport | ||
| 71 | commands["open"] = cmdOpen | ||
| 72 | commands["doctor"] = cmdDoctor | ||
| 73 | commands["upgrade"] = cmdUpgrade | ||
| 74 | commands["delete"] = cmdDelete | ||
| 75 | commands["prune"] = cmdPrune | ||
| 76 | } | ||
| 77 | |||
| 78 | func init() { | ||
| 79 | registerCommands() | ||
| 80 | } | ||
| 81 | |||
| 82 | // Run parses os.Args-style arguments (args[0] is the program name), | ||
| 83 | // dispatches to the matching subcommand, and returns a process exit code. | ||
| 84 | // buildContext is the embedded build-context filesystem from /assets.go. | ||
| 85 | func Run(args []string, buildContext fs.FS) int { | ||
| 86 | BuildContext = buildContext | ||
| 87 | |||
| 88 | if len(args) < 2 { | ||
| 89 | printUsage(os.Stdout) | ||
| 90 | return 1 | ||
| 91 | } | ||
| 92 | |||
| 93 | sub := args[1] | ||
| 94 | rest := args[2:] | ||
| 95 | |||
| 96 | switch sub { | ||
| 97 | case "-h", "-help", "--help", "help": | ||
| 98 | printUsage(os.Stdout) | ||
| 99 | return 0 | ||
| 100 | case "-v", "-version", "--version", "version": | ||
| 101 | fmt.Println(version.Version) | ||
| 102 | return 0 | ||
| 103 | } | ||
| 104 | |||
| 105 | if canonical, ok := aliases[sub]; ok { | ||
| 106 | sub = canonical | ||
| 107 | } | ||
| 108 | |||
| 109 | fn, ok := commands[sub] | ||
| 110 | if !ok { | ||
| 111 | fmt.Fprintf(os.Stderr, "tx9: unknown command %q\n\n", sub) | ||
| 112 | printUsage(os.Stderr) | ||
| 113 | return 1 | ||
| 114 | } | ||
| 115 | |||
| 116 | if err := fn(rest); err != nil { | ||
| 117 | fmt.Fprintf(os.Stderr, "tx9: %v\n", err) | ||
| 118 | return 1 | ||
| 119 | } | ||
| 120 | return 0 | ||
| 121 | } | ||
| 122 | |||
| 123 | func printUsage(w *os.File) { | ||
| 124 | fmt.Fprintf(w, "tx9 %s — manage hermes boxes\n\n", version.Version) | ||
| 125 | fmt.Fprintln(w, "Usage: tx9 <command> [args]") | ||
| 126 | fmt.Fprintln(w, "\nCommands:") | ||
| 127 | for _, name := range commandOrder { | ||
| 128 | fmt.Fprintf(w, " %-10s %s\n", name, commandHelp[name]) | ||
| 129 | } | ||
| 130 | fmt.Fprintln(w, "\nAliases: ssh -> enter, export -> backup") | ||
| 131 | fmt.Fprintln(w, "\nOther:") | ||
| 132 | fmt.Fprintln(w, " help show this message") | ||
| 133 | fmt.Fprintln(w, " version print the CLI version") | ||
| 134 | fmt.Fprintln(w, "\nFlags:") | ||
| 135 | fmt.Fprintln(w, " -h, --help show this message") | ||
| 136 | fmt.Fprintln(w, " -v, --version print the CLI version") | ||
| 137 | } | ||
| @@ -0,0 +1,317 @@ | |||
| 1 | // Package docker is a thin wrapper over the official Docker Engine Go SDK | ||
| 2 | // (decision 8, tx9-cli-design.md). It exposes only what box lifecycle | ||
| 3 | // commands need — images, containers, networks, volumes, exec — behind a | ||
| 4 | // small struct so a remote daemon (SSH tunnel / DOCKER_HOST) can slot in | ||
| 5 | // later without restructuring callers. | ||
| 6 | package docker | ||
| 7 | |||
| 8 | import ( | ||
| 9 | "bytes" | ||
| 10 | "context" | ||
| 11 | "fmt" | ||
| 12 | "io" | ||
| 13 | |||
| 14 | "github.com/docker/docker/api/types/build" | ||
| 15 | "github.com/docker/docker/api/types/container" | ||
| 16 | "github.com/docker/docker/api/types/filters" | ||
| 17 | "github.com/docker/docker/api/types/image" | ||
| 18 | "github.com/docker/docker/api/types/network" | ||
| 19 | "github.com/docker/docker/api/types/volume" | ||
| 20 | "github.com/docker/docker/client" | ||
| 21 | "github.com/docker/docker/pkg/jsonmessage" | ||
| 22 | "github.com/docker/docker/pkg/stdcopy" | ||
| 23 | "github.com/docker/go-connections/nat" | ||
| 24 | |||
| 25 | "github.com/davis7dotsh/tx9/internal/assets" | ||
| 26 | ) | ||
| 27 | |||
| 28 | // Client wraps the Docker Engine API client with tx9-shaped helpers. | ||
| 29 | type Client struct { | ||
| 30 | cli *client.Client | ||
| 31 | } | ||
| 32 | |||
| 33 | // NewClient connects to the Docker daemon from the environment | ||
| 34 | // (DOCKER_HOST/DOCKER_CERT_PATH/etc, same as the docker CLI) and negotiates | ||
| 35 | // the API version so it works across a range of daemon versions. | ||
| 36 | func NewClient(ctx context.Context) (*Client, error) { | ||
| 37 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) | ||
| 38 | if err != nil { | ||
| 39 | return nil, fmt.Errorf("docker: new client: %w", err) | ||
| 40 | } | ||
| 41 | if _, err := cli.Ping(ctx); err != nil { | ||
| 42 | cli.Close() | ||
| 43 | return nil, fmt.Errorf("docker: daemon unreachable: %w", err) | ||
| 44 | } | ||
| 45 | return &Client{cli: cli}, nil | ||
| 46 | } | ||
| 47 | |||
| 48 | // Close releases the underlying HTTP client's connections. | ||
| 49 | func (c *Client) Close() error { | ||
| 50 | return c.cli.Close() | ||
| 51 | } | ||
| 52 | |||
| 53 | // Raw exposes the underlying SDK client for calls this wrapper doesn't | ||
| 54 | // (yet) cover. | ||
| 55 | func (c *Client) Raw() *client.Client { | ||
| 56 | return c.cli | ||
| 57 | } | ||
| 58 | |||
| 59 | // ImageExists reports whether tag is present in the local image store. | ||
| 60 | func (c *Client) ImageExists(ctx context.Context, tag string) (bool, error) { | ||
| 61 | _, err := c.cli.ImageInspect(ctx, tag) | ||
| 62 | if err == nil { | ||
| 63 | return true, nil | ||
| 64 | } | ||
| 65 | if client.IsErrNotFound(err) { | ||
| 66 | return false, nil | ||
| 67 | } | ||
| 68 | return false, fmt.Errorf("docker: image inspect %s: %w", tag, err) | ||
| 69 | } | ||
| 70 | |||
| 71 | // BuildImage builds tag from buildContext (a tar stream — see | ||
| 72 | // internal/assets.BuildContextTar), streaming daemon build progress as | ||
| 73 | // human-readable lines to progress. | ||
| 74 | func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, tag string, progress io.Writer) error { | ||
| 75 | resp, err := c.cli.ImageBuild(ctx, buildContext, build.ImageBuildOptions{ | ||
| 76 | Tags: []string{tag}, | ||
| 77 | Dockerfile: assets.DockerfilePath, | ||
| 78 | Remove: true, | ||
| 79 | }) | ||
| 80 | if err != nil { | ||
| 81 | return fmt.Errorf("docker: image build %s: %w", tag, err) | ||
| 82 | } | ||
| 83 | defer resp.Body.Close() | ||
| 84 | |||
| 85 | if err := jsonmessage.DisplayJSONMessagesStream(resp.Body, progress, 0, false, nil); err != nil { | ||
| 86 | return fmt.Errorf("docker: image build %s: %w", tag, err) | ||
| 87 | } | ||
| 88 | return nil | ||
| 89 | } | ||
| 90 | |||
| 91 | // ListBoxContainers returns every container tx9 has ever labeled | ||
| 92 | // (LabelManaged), running or not, so `tx9 list` can reconstruct state from | ||
| 93 | // the daemon alone. | ||
| 94 | func (c *Client) ListBoxContainers(ctx context.Context) ([]container.Summary, error) { | ||
| 95 | f := filters.NewArgs(filters.Arg("label", LabelManaged+"=1")) | ||
| 96 | containers, err := c.cli.ContainerList(ctx, container.ListOptions{All: true, Filters: f}) | ||
| 97 | if err != nil { | ||
| 98 | return nil, fmt.Errorf("docker: list containers: %w", err) | ||
| 99 | } | ||
| 100 | return containers, nil | ||
| 101 | } | ||
| 102 | |||
| 103 | // NetworkCreate creates a private bridge network for a box, with IPv6 | ||
| 104 | // explicitly disabled at the network level (dossier §1.4 — compose's | ||
| 105 | // default when no IPv6 config is requested; tx9 replicates it explicitly). | ||
| 106 | func (c *Client) NetworkCreate(ctx context.Context, name string, labels map[string]string) (string, error) { | ||
| 107 | enableIPv6 := false | ||
| 108 | resp, err := c.cli.NetworkCreate(ctx, name, network.CreateOptions{ | ||
| 109 | Driver: "bridge", | ||
| 110 | EnableIPv6: &enableIPv6, | ||
| 111 | Labels: labels, | ||
| 112 | }) | ||
| 113 | if err != nil { | ||
| 114 | return "", fmt.Errorf("docker: network create %s: %w", name, err) | ||
| 115 | } | ||
| 116 | return resp.ID, nil | ||
| 117 | } | ||
| 118 | |||
| 119 | // NetworkRemove removes a network by ID or name. | ||
| 120 | func (c *Client) NetworkRemove(ctx context.Context, id string) error { | ||
| 121 | if err := c.cli.NetworkRemove(ctx, id); err != nil { | ||
| 122 | return fmt.Errorf("docker: network remove %s: %w", id, err) | ||
| 123 | } | ||
| 124 | return nil | ||
| 125 | } | ||
| 126 | |||
| 127 | // VolumeCreate creates a named volume. | ||
| 128 | func (c *Client) VolumeCreate(ctx context.Context, name string, labels map[string]string) (string, error) { | ||
| 129 | v, err := c.cli.VolumeCreate(ctx, volume.CreateOptions{Name: name, Labels: labels}) | ||
| 130 | if err != nil { | ||
| 131 | return "", fmt.Errorf("docker: volume create %s: %w", name, err) | ||
| 132 | } | ||
| 133 | return v.Name, nil | ||
| 134 | } | ||
| 135 | |||
| 136 | // VolumeRemove removes a named volume. | ||
| 137 | func (c *Client) VolumeRemove(ctx context.Context, name string, force bool) error { | ||
| 138 | if err := c.cli.VolumeRemove(ctx, name, force); err != nil { | ||
| 139 | return fmt.Errorf("docker: volume remove %s: %w", name, err) | ||
| 140 | } | ||
| 141 | return nil | ||
| 142 | } | ||
| 143 | |||
| 144 | // ContainerSpec describes a container tx9 wants created. It only covers the | ||
| 145 | // fields the box topology (dossier §1) actually uses — not the full Engine | ||
| 146 | // API surface. | ||
| 147 | type ContainerSpec struct { | ||
| 148 | Name string | ||
| 149 | Image string | ||
| 150 | Entrypoint []string | ||
| 151 | Env []string | ||
| 152 | Labels map[string]string | ||
| 153 | Hostname string | ||
| 154 | NetworkID string | ||
| 155 | NetworkAliases []string | ||
| 156 | Binds []string // "volume:/container/path" or "volume:/path:ro" | ||
| 157 | ExposedPorts nat.PortSet | ||
| 158 | PortBindings nat.PortMap | ||
| 159 | Sysctls map[string]string | ||
| 160 | NanoCPUs int64 | ||
| 161 | MemoryBytes int64 | ||
| 162 | } | ||
| 163 | |||
| 164 | // ContainerCreate creates (but does not start) a container per spec, with | ||
| 165 | // `--init` and `restart: unless-stopped` baked in (dossier §1.3 — every box | ||
| 166 | // container uses both). | ||
| 167 | func (c *Client) ContainerCreate(ctx context.Context, spec ContainerSpec) (string, error) { | ||
| 168 | initEnabled := true | ||
| 169 | cfg := &container.Config{ | ||
| 170 | Image: spec.Image, | ||
| 171 | Entrypoint: spec.Entrypoint, | ||
| 172 | Env: spec.Env, | ||
| 173 | Labels: spec.Labels, | ||
| 174 | Hostname: spec.Hostname, | ||
| 175 | ExposedPorts: spec.ExposedPorts, | ||
| 176 | } | ||
| 177 | hostCfg := &container.HostConfig{ | ||
| 178 | Binds: spec.Binds, | ||
| 179 | PortBindings: spec.PortBindings, | ||
| 180 | Sysctls: spec.Sysctls, | ||
| 181 | Init: &initEnabled, | ||
| 182 | RestartPolicy: container.RestartPolicy{ | ||
| 183 | Name: container.RestartPolicyUnlessStopped, | ||
| 184 | }, | ||
| 185 | Resources: container.Resources{ | ||
| 186 | NanoCPUs: spec.NanoCPUs, | ||
| 187 | Memory: spec.MemoryBytes, | ||
| 188 | }, | ||
| 189 | } | ||
| 190 | |||
| 191 | var netCfg *network.NetworkingConfig | ||
| 192 | if spec.NetworkID != "" { | ||
| 193 | netCfg = &network.NetworkingConfig{ | ||
| 194 | EndpointsConfig: map[string]*network.EndpointSettings{ | ||
| 195 | spec.NetworkID: {Aliases: spec.NetworkAliases}, | ||
| 196 | }, | ||
| 197 | } | ||
| 198 | } | ||
| 199 | |||
| 200 | resp, err := c.cli.ContainerCreate(ctx, cfg, hostCfg, netCfg, nil, spec.Name) | ||
| 201 | if err != nil { | ||
| 202 | return "", fmt.Errorf("docker: container create %s: %w", spec.Name, err) | ||
| 203 | } | ||
| 204 | return resp.ID, nil | ||
| 205 | } | ||
| 206 | |||
| 207 | // ContainerStart starts a previously-created container. | ||
| 208 | func (c *Client) ContainerStart(ctx context.Context, id string) error { | ||
| 209 | if err := c.cli.ContainerStart(ctx, id, container.StartOptions{}); err != nil { | ||
| 210 | return fmt.Errorf("docker: container start %s: %w", id, err) | ||
| 211 | } | ||
| 212 | return nil | ||
| 213 | } | ||
| 214 | |||
| 215 | // ContainerStop stops a running container (nil timeout uses the daemon | ||
| 216 | // default grace period). | ||
| 217 | func (c *Client) ContainerStop(ctx context.Context, id string, timeoutSeconds *int) error { | ||
| 218 | if err := c.cli.ContainerStop(ctx, id, container.StopOptions{Timeout: timeoutSeconds}); err != nil { | ||
| 219 | return fmt.Errorf("docker: container stop %s: %w", id, err) | ||
| 220 | } | ||
| 221 | return nil | ||
| 222 | } | ||
| 223 | |||
| 224 | // ContainerRemove removes a container. force also kills it if running. | ||
| 225 | func (c *Client) ContainerRemove(ctx context.Context, id string, force bool) error { | ||
| 226 | if err := c.cli.ContainerRemove(ctx, id, container.RemoveOptions{Force: force, RemoveVolumes: false}); err != nil { | ||
| 227 | return fmt.Errorf("docker: container remove %s: %w", id, err) | ||
| 228 | } | ||
| 229 | return nil | ||
| 230 | } | ||
| 231 | |||
| 232 | // ContainerInspect returns full container state. | ||
| 233 | func (c *Client) ContainerInspect(ctx context.Context, id string) (container.InspectResponse, error) { | ||
| 234 | resp, err := c.cli.ContainerInspect(ctx, id) | ||
| 235 | if err != nil { | ||
| 236 | return container.InspectResponse{}, fmt.Errorf("docker: container inspect %s: %w", id, err) | ||
| 237 | } | ||
| 238 | return resp, nil | ||
| 239 | } | ||
| 240 | |||
| 241 | // ImageInspect returns full image metadata (used by `list` to show | ||
| 242 | // per-box image version drift). | ||
| 243 | func (c *Client) ImageInspect(ctx context.Context, tag string) (image.InspectResponse, error) { | ||
| 244 | resp, err := c.cli.ImageInspect(ctx, tag) | ||
| 245 | if err != nil { | ||
| 246 | return image.InspectResponse{}, fmt.Errorf("docker: image inspect %s: %w", tag, err) | ||
| 247 | } | ||
| 248 | return resp, nil | ||
| 249 | } | ||
| 250 | |||
| 251 | // ImageList returns image summaries matching a reference filter (e.g. | ||
| 252 | // "tx9-box:*"), used by `tx9 prune` to find stale box images. | ||
| 253 | func (c *Client) ImageList(ctx context.Context, referenceFilter string) ([]image.Summary, error) { | ||
| 254 | f := filters.NewArgs(filters.Arg("reference", referenceFilter)) | ||
| 255 | images, err := c.cli.ImageList(ctx, image.ListOptions{Filters: f}) | ||
| 256 | if err != nil { | ||
| 257 | return nil, fmt.Errorf("docker: list images %s: %w", referenceFilter, err) | ||
| 258 | } | ||
| 259 | return images, nil | ||
| 260 | } | ||
| 261 | |||
| 262 | // ImageRemove removes an image by ID or tag. | ||
| 263 | func (c *Client) ImageRemove(ctx context.Context, ref string) error { | ||
| 264 | if _, err := c.cli.ImageRemove(ctx, ref, image.RemoveOptions{}); err != nil { | ||
| 265 | return fmt.Errorf("docker: image remove %s: %w", ref, err) | ||
| 266 | } | ||
| 267 | return nil | ||
| 268 | } | ||
| 269 | |||
| 270 | // ExecResult is the outcome of a non-interactive Exec call. | ||
| 271 | type ExecResult struct { | ||
| 272 | Stdout string | ||
| 273 | Stderr string | ||
| 274 | ExitCode int | ||
| 275 | } | ||
| 276 | |||
| 277 | // Exec runs cmd inside containerID (no TTY), waits for completion, and | ||
| 278 | // returns its combined stdout/stderr and exit code. This is the | ||
| 279 | // "remote-control" pattern used to invoke `hb` inside the agent container | ||
| 280 | // (dossier §3, `_guest_hb`) for calls like `up`/`doctor`/`wire-once`. | ||
| 281 | // | ||
| 282 | // Interactive/TTY exec (e.g. `tx9 enter`) needs raw terminal handling this | ||
| 283 | // helper doesn't provide and should use Raw() directly. | ||
| 284 | func (c *Client) Exec(ctx context.Context, containerID string, cmd []string, env []string, user string) (ExecResult, error) { | ||
| 285 | created, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{ | ||
| 286 | Cmd: cmd, | ||
| 287 | Env: env, | ||
| 288 | User: user, | ||
| 289 | AttachStdout: true, | ||
| 290 | AttachStderr: true, | ||
| 291 | }) | ||
| 292 | if err != nil { | ||
| 293 | return ExecResult{}, fmt.Errorf("docker: exec create: %w", err) | ||
| 294 | } | ||
| 295 | |||
| 296 | attach, err := c.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{}) | ||
| 297 | if err != nil { | ||
| 298 | return ExecResult{}, fmt.Errorf("docker: exec attach: %w", err) | ||
| 299 | } | ||
| 300 | defer attach.Close() | ||
| 301 | |||
| 302 | var stdout, stderr bytes.Buffer | ||
| 303 | if _, err := stdcopy.StdCopy(&stdout, &stderr, attach.Reader); err != nil { | ||
| 304 | return ExecResult{}, fmt.Errorf("docker: exec read output: %w", err) | ||
| 305 | } | ||
| 306 | |||
| 307 | inspect, err := c.cli.ContainerExecInspect(ctx, created.ID) | ||
| 308 | if err != nil { | ||
| 309 | return ExecResult{}, fmt.Errorf("docker: exec inspect: %w", err) | ||
| 310 | } | ||
| 311 | |||
| 312 | return ExecResult{ | ||
| 313 | Stdout: stdout.String(), | ||
| 314 | Stderr: stderr.String(), | ||
| 315 | ExitCode: inspect.ExitCode, | ||
| 316 | }, nil | ||
| 317 | } | ||
| @@ -0,0 +1,137 @@ | |||
| 1 | package docker | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "fmt" | ||
| 6 | "io" | ||
| 7 | "sync" | ||
| 8 | |||
| 9 | "github.com/docker/docker/api/types/container" | ||
| 10 | "github.com/docker/docker/pkg/stdcopy" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // ExecStream runs cmd inside the already-running container containerID, | ||
| 14 | // like Exec, but streams stdout/stderr directly to the caller's writers | ||
| 15 | // instead of buffering them in memory. This is what `tx9 backup` uses to | ||
| 16 | // pull a multi-gigabyte tar stream out of the agent container's /data | ||
| 17 | // volume without holding the whole archive in RAM. | ||
| 18 | func (c *Client) ExecStream(ctx context.Context, containerID string, cmd []string, env []string, user string, stdout, stderr io.Writer) (int, error) { | ||
| 19 | created, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{ | ||
| 20 | Cmd: cmd, | ||
| 21 | Env: env, | ||
| 22 | User: user, | ||
| 23 | AttachStdout: true, | ||
| 24 | AttachStderr: true, | ||
| 25 | }) | ||
| 26 | if err != nil { | ||
| 27 | return 0, fmt.Errorf("docker: exec create: %w", err) | ||
| 28 | } | ||
| 29 | |||
| 30 | attach, err := c.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{}) | ||
| 31 | if err != nil { | ||
| 32 | return 0, fmt.Errorf("docker: exec attach: %w", err) | ||
| 33 | } | ||
| 34 | defer attach.Close() | ||
| 35 | |||
| 36 | if _, err := stdcopy.StdCopy(stdout, stderr, attach.Reader); err != nil { | ||
| 37 | return 0, fmt.Errorf("docker: exec read output: %w", err) | ||
| 38 | } | ||
| 39 | |||
| 40 | inspect, err := c.cli.ContainerExecInspect(ctx, created.ID) | ||
| 41 | if err != nil { | ||
| 42 | return 0, fmt.Errorf("docker: exec inspect: %w", err) | ||
| 43 | } | ||
| 44 | return inspect.ExitCode, nil | ||
| 45 | } | ||
| 46 | |||
| 47 | // EphemeralOpts parameterizes RunEphemeral. | ||
| 48 | type EphemeralOpts struct { | ||
| 49 | Image string | ||
| 50 | Entrypoint []string | ||
| 51 | Cmd []string | ||
| 52 | Env []string | ||
| 53 | Binds []string // "volume:/container/path" | ||
| 54 | Stdin io.Reader // optional; nil means no stdin is attached | ||
| 55 | Stdout io.Writer | ||
| 56 | Stderr io.Writer | ||
| 57 | } | ||
| 58 | |||
| 59 | // RunEphemeral creates, starts, waits for, and always removes a throwaway | ||
| 60 | // container from opts.Image with opts.Binds mounted — the "stage-then- | ||
| 61 | // promote" pattern `tx9 import` uses to write into a box's agent-data | ||
| 62 | // volume before the agent container itself has ever started (dossier | ||
| 63 | // §7.2's `docker run --rm -i -v ... --entrypoint bash "$IMAGE" -c '...'`). | ||
| 64 | // It returns the container's exit code; a non-nil error means the | ||
| 65 | // container could not be run at all (create/attach/start/wait failure), | ||
| 66 | // not merely that the command inside it exited non-zero. | ||
| 67 | func (c *Client) RunEphemeral(ctx context.Context, opts EphemeralOpts) (int, error) { | ||
| 68 | hasStdin := opts.Stdin != nil | ||
| 69 | cfg := &container.Config{ | ||
| 70 | Image: opts.Image, | ||
| 71 | Entrypoint: opts.Entrypoint, | ||
| 72 | Cmd: opts.Cmd, | ||
| 73 | Env: opts.Env, | ||
| 74 | AttachStdin: hasStdin, | ||
| 75 | AttachStdout: true, | ||
| 76 | AttachStderr: true, | ||
| 77 | OpenStdin: hasStdin, | ||
| 78 | StdinOnce: hasStdin, | ||
| 79 | } | ||
| 80 | hostCfg := &container.HostConfig{Binds: opts.Binds} | ||
| 81 | |||
| 82 | resp, err := c.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, "") | ||
| 83 | if err != nil { | ||
| 84 | return 0, fmt.Errorf("docker: ephemeral create: %w", err) | ||
| 85 | } | ||
| 86 | defer c.cli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true}) | ||
| 87 | |||
| 88 | attach, err := c.cli.ContainerAttach(ctx, resp.ID, container.AttachOptions{ | ||
| 89 | Stream: true, | ||
| 90 | Stdin: hasStdin, | ||
| 91 | Stdout: true, | ||
| 92 | Stderr: true, | ||
| 93 | }) | ||
| 94 | if err != nil { | ||
| 95 | return 0, fmt.Errorf("docker: ephemeral attach: %w", err) | ||
| 96 | } | ||
| 97 | defer attach.Close() | ||
| 98 | |||
| 99 | // Reserve "not running" wait BEFORE start so we can't miss a fast exit. | ||
| 100 | statusCh, waitErrCh := c.cli.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning) | ||
| 101 | |||
| 102 | if err := c.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { | ||
| 103 | return 0, fmt.Errorf("docker: ephemeral start: %w", err) | ||
| 104 | } | ||
| 105 | |||
| 106 | var stdinWG sync.WaitGroup | ||
| 107 | if hasStdin { | ||
| 108 | stdinWG.Add(1) | ||
| 109 | go func() { | ||
| 110 | defer stdinWG.Done() | ||
| 111 | io.Copy(attach.Conn, opts.Stdin) | ||
| 112 | attach.CloseWrite() | ||
| 113 | }() | ||
| 114 | } | ||
| 115 | |||
| 116 | copyDone := make(chan error, 1) | ||
| 117 | go func() { | ||
| 118 | _, cerr := stdcopy.StdCopy(opts.Stdout, opts.Stderr, attach.Reader) | ||
| 119 | copyDone <- cerr | ||
| 120 | }() | ||
| 121 | |||
| 122 | var exitCode int | ||
| 123 | select { | ||
| 124 | case werr := <-waitErrCh: | ||
| 125 | return 0, fmt.Errorf("docker: ephemeral wait: %w", werr) | ||
| 126 | case status := <-statusCh: | ||
| 127 | exitCode = int(status.StatusCode) | ||
| 128 | } | ||
| 129 | |||
| 130 | if hasStdin { | ||
| 131 | stdinWG.Wait() | ||
| 132 | } | ||
| 133 | if cerr := <-copyDone; cerr != nil { | ||
| 134 | return exitCode, fmt.Errorf("docker: ephemeral read output: %w", cerr) | ||
| 135 | } | ||
| 136 | return exitCode, nil | ||
| 137 | } | ||
| @@ -0,0 +1,31 @@ | |||
| 1 | package docker | ||
| 2 | |||
| 3 | // Label keys tx9 stamps on every Docker object it creates, so `tx9 list` | ||
| 4 | // can reconstruct truth from the daemon even if ~/.tx9 is lost (decision 2). | ||
| 5 | const ( | ||
| 6 | LabelManaged = "tx9" // "1" on every tx9-managed object | ||
| 7 | LabelBox = "tx9.box" // box name | ||
| 8 | LabelVersion = "tx9.version" // image/CLI version that created the object | ||
| 9 | LabelRole = "tx9.role" // "agent" or "executor" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // Roles for LabelRole. | ||
| 13 | const ( | ||
| 14 | RoleAgent = "agent" | ||
| 15 | RoleExecutor = "executor" | ||
| 16 | ) | ||
| 17 | |||
| 18 | // BoxLabels returns the standard label set for an object belonging to box | ||
| 19 | // name, tagged with version, playing role (RoleAgent/RoleExecutor, or "" | ||
| 20 | // for objects — like the per-box network — that aren't container-specific). | ||
| 21 | func BoxLabels(name, version, role string) map[string]string { | ||
| 22 | labels := map[string]string{ | ||
| 23 | LabelManaged: "1", | ||
| 24 | LabelBox: name, | ||
| 25 | LabelVersion: version, | ||
| 26 | } | ||
| 27 | if role != "" { | ||
| 28 | labels[LabelRole] = role | ||
| 29 | } | ||
| 30 | return labels | ||
| 31 | } | ||
| @@ -0,0 +1,42 @@ | |||
| 1 | // Package lock provides per-box, non-blocking flock-based mutual exclusion, | ||
| 2 | // mirroring boxd's `_lock()` semantics: fail fast, never queue (dossier §8.1). | ||
| 3 | package lock | ||
| 4 | |||
| 5 | import ( | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "strings" | ||
| 10 | |||
| 11 | "golang.org/x/sys/unix" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // Acquire takes an exclusive, non-blocking flock on path (creating it if | ||
| 15 | // needed) and returns a release func. If another process already holds the | ||
| 16 | // lock, it returns an error immediately rather than blocking. | ||
| 17 | func Acquire(path string) (release func(), err error) { | ||
| 18 | f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) | ||
| 19 | if err != nil { | ||
| 20 | return nil, fmt.Errorf("lock: acquire: %w", err) | ||
| 21 | } | ||
| 22 | if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { | ||
| 23 | f.Close() | ||
| 24 | if err == unix.EWOULDBLOCK { | ||
| 25 | return nil, fmt.Errorf("lock: another tx9 operation is running on box %s", boxNameFromPath(path)) | ||
| 26 | } | ||
| 27 | return nil, fmt.Errorf("lock: acquire: %w", err) | ||
| 28 | } | ||
| 29 | released := false | ||
| 30 | return func() { | ||
| 31 | if released { | ||
| 32 | return | ||
| 33 | } | ||
| 34 | released = true | ||
| 35 | unix.Flock(int(f.Fd()), unix.LOCK_UN) | ||
| 36 | f.Close() | ||
| 37 | }, nil | ||
| 38 | } | ||
| 39 | |||
| 40 | func boxNameFromPath(path string) string { | ||
| 41 | return strings.TrimSuffix(filepath.Base(path), ".lock") | ||
| 42 | } | ||
| @@ -0,0 +1,103 @@ | |||
| 1 | // Package names generates and validates friendly box names (e.g. "large-cat"). | ||
| 2 | package names | ||
| 3 | |||
| 4 | import ( | ||
| 5 | "fmt" | ||
| 6 | "math/rand/v2" | ||
| 7 | "regexp" | ||
| 8 | ) | ||
| 9 | |||
| 10 | var adjectives = []string{ | ||
| 11 | "large", "small", "quick", "slow", "quiet", "loud", "brave", "calm", | ||
| 12 | "eager", "gentle", "happy", "jolly", "kind", "lively", "mighty", "noble", | ||
| 13 | "proud", "silly", "witty", "young", "amber", "azure", "bold", "bright", | ||
| 14 | "clever", "crisp", "dapper", "fuzzy", "grumpy", "handy", "icy", "keen", | ||
| 15 | "lucky", "merry", "nifty", "plucky", "rowdy", "sturdy", "tidy", "vivid", | ||
| 16 | } | ||
| 17 | |||
| 18 | var animals = []string{ | ||
| 19 | "cat", "dog", "fox", "owl", "wolf", "bear", "hawk", "seal", "crane", | ||
| 20 | "otter", "lynx", "moose", "heron", "raven", "gecko", "ibis", "koala", | ||
| 21 | "mole", "newt", "puma", "quail", "ram", "shark", "toad", "vole", | ||
| 22 | "whale", "yak", "zebra", "bison", "civet", "eagle", "ferret", "goose", | ||
| 23 | "hare", "jay", "kite", "loris", "mink", "panda", "stoat", | ||
| 24 | } | ||
| 25 | |||
| 26 | // reserved names must never be generated or accepted, to avoid colliding | ||
| 27 | // with CLI vocabulary or Docker/filesystem-sensitive words. | ||
| 28 | var reserved = map[string]bool{ | ||
| 29 | "create": true, "list": true, "enter": true, "ssh": true, | ||
| 30 | "start": true, "stop": true, "backup": true, "export": true, | ||
| 31 | "import": true, "open": true, "doctor": true, "upgrade": true, | ||
| 32 | "delete": true, "prune": true, "help": true, "version": true, | ||
| 33 | "all": true, "none": true, "default": true, | ||
| 34 | } | ||
| 35 | |||
| 36 | var validPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) | ||
| 37 | |||
| 38 | const maxNameLen = 32 | ||
| 39 | |||
| 40 | // Generate returns a friendly "adjective-animal" name not present in taken, | ||
| 41 | // picked randomly rather than always starting from the same fixed point in | ||
| 42 | // the word-list product (which used to mean the first box ever created | ||
| 43 | // always got the same name). No cryptographic randomness is needed here, | ||
| 44 | // so math/rand/v2 is fine. | ||
| 45 | // | ||
| 46 | // It tries a bounded number of random (adjective, animal) picks first; if | ||
| 47 | // that's unlucky (the product space is nearly exhausted), it falls back to | ||
| 48 | // the exhaustive scan that used to be the only strategy; if even the whole | ||
| 49 | // product space turns out to be taken, it disambiguates with a random | ||
| 50 | // numeric suffix. | ||
| 51 | func Generate(taken map[string]bool) (string, error) { | ||
| 52 | maxAttempts := 10 * len(adjectives) * len(animals) | ||
| 53 | for attempt := 0; attempt < maxAttempts; attempt++ { | ||
| 54 | adj := adjectives[rand.IntN(len(adjectives))] | ||
| 55 | an := animals[rand.IntN(len(animals))] | ||
| 56 | name := adj + "-" + an | ||
| 57 | if !taken[name] { | ||
| 58 | return name, nil | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | // Random attempts didn't land on a free name — fall back to an | ||
| 63 | // exhaustive scan so a merely-crowded (but not full) namespace still | ||
| 64 | // resolves deterministically instead of relying on luck. | ||
| 65 | for _, adj := range adjectives { | ||
| 66 | for _, an := range animals { | ||
| 67 | name := adj + "-" + an | ||
| 68 | if !taken[name] { | ||
| 69 | return name, nil | ||
| 70 | } | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 74 | // Word-list space exhausted (very unlikely) — disambiguate with a | ||
| 75 | // random 4-digit suffix on a random base name. | ||
| 76 | for attempt := 0; attempt < 100; attempt++ { | ||
| 77 | adj := adjectives[rand.IntN(len(adjectives))] | ||
| 78 | an := animals[rand.IntN(len(animals))] | ||
| 79 | name := fmt.Sprintf("%s-%s-%04d", adj, an, rand.IntN(10000)) | ||
| 80 | if !taken[name] { | ||
| 81 | return name, nil | ||
| 82 | } | ||
| 83 | } | ||
| 84 | return "", fmt.Errorf("names: generate: exhausted candidates") | ||
| 85 | } | ||
| 86 | |||
| 87 | // Validate reports whether name is an acceptable box name: lowercase | ||
| 88 | // [a-z0-9_-], starting with an alphanumeric, max 32 chars, not reserved. | ||
| 89 | func Validate(name string) error { | ||
| 90 | if name == "" { | ||
| 91 | return fmt.Errorf("names: validate: name must not be empty") | ||
| 92 | } | ||
| 93 | if len(name) > maxNameLen { | ||
| 94 | return fmt.Errorf("names: validate: name %q exceeds %d characters", name, maxNameLen) | ||
| 95 | } | ||
| 96 | if !validPattern.MatchString(name) { | ||
| 97 | return fmt.Errorf("names: validate: name %q must match %s", name, validPattern.String()) | ||
| 98 | } | ||
| 99 | if reserved[name] { | ||
| 100 | return fmt.Errorf("names: validate: name %q is reserved", name) | ||
| 101 | } | ||
| 102 | return nil | ||
| 103 | } | ||
| @@ -0,0 +1,69 @@ | |||
| 1 | package names | ||
| 2 | |||
| 3 | import "testing" | ||
| 4 | |||
| 5 | func TestGenerate(t *testing.T) { | ||
| 6 | taken := map[string]bool{} | ||
| 7 | name, err := Generate(taken) | ||
| 8 | if err != nil { | ||
| 9 | t.Fatalf("Generate: %v", err) | ||
| 10 | } | ||
| 11 | if err := Validate(name); err != nil { | ||
| 12 | t.Fatalf("Generate produced invalid name %q: %v", name, err) | ||
| 13 | } | ||
| 14 | taken[name] = true | ||
| 15 | name2, err := Generate(taken) | ||
| 16 | if err != nil { | ||
| 17 | t.Fatalf("Generate: %v", err) | ||
| 18 | } | ||
| 19 | if name2 == name { | ||
| 20 | t.Fatalf("Generate: returned already-taken name %q", name) | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | // TestGenerate_NotAlwaysTheSameFirstName guards against the original bug | ||
| 25 | // where Generate always walked the adjective x animal product in the same | ||
| 26 | // fixed order, so the very first box ever created (empty taken map) always | ||
| 27 | // got the same name. With randomized picks, repeated calls against a fresh | ||
| 28 | // empty map should not all agree. | ||
| 29 | func TestGenerate_NotAlwaysTheSameFirstName(t *testing.T) { | ||
| 30 | first, err := Generate(map[string]bool{}) | ||
| 31 | if err != nil { | ||
| 32 | t.Fatalf("Generate: %v", err) | ||
| 33 | } | ||
| 34 | for i := 0; i < 50; i++ { | ||
| 35 | name, err := Generate(map[string]bool{}) | ||
| 36 | if err != nil { | ||
| 37 | t.Fatalf("Generate: %v", err) | ||
| 38 | } | ||
| 39 | if name != first { | ||
| 40 | return | ||
| 41 | } | ||
| 42 | } | ||
| 43 | t.Fatalf("Generate returned %q on every one of 51 calls against an empty taken map; expected randomization", first) | ||
| 44 | } | ||
| 45 | |||
| 46 | func TestValidate(t *testing.T) { | ||
| 47 | cases := []struct { | ||
| 48 | name string | ||
| 49 | ok bool | ||
| 50 | }{ | ||
| 51 | {"large-cat", true}, | ||
| 52 | {"my_box", true}, | ||
| 53 | {"box9", true}, | ||
| 54 | {"", false}, | ||
| 55 | {"Bad-Case", false}, | ||
| 56 | {"-leading-dash", false}, | ||
| 57 | {"create", false}, | ||
| 58 | {"way-too-long-name-that-exceeds-the-maximum-length-allowed", false}, | ||
| 59 | } | ||
| 60 | for _, c := range cases { | ||
| 61 | err := Validate(c.name) | ||
| 62 | if c.ok && err != nil { | ||
| 63 | t.Errorf("Validate(%q): want ok, got %v", c.name, err) | ||
| 64 | } | ||
| 65 | if !c.ok && err == nil { | ||
| 66 | t.Errorf("Validate(%q): want error, got nil", c.name) | ||
| 67 | } | ||
| 68 | } | ||
| 69 | } | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | package selfupdate | ||
| 2 | |||
| 3 | import "fmt" | ||
| 4 | |||
| 5 | // AssetName returns the release asset name tx9 publishes for a given | ||
| 6 | // GOOS/GOARCH pair: a plain, uncompressed executable, no extension. This | ||
| 7 | // is the one naming invariant shared by this package, the Makefile's | ||
| 8 | // `dist` target, and .github/workflows/release.yml — all three must agree, | ||
| 9 | // or self-update silently can't find its own binary. | ||
| 10 | func AssetName(goos, goarch string) string { | ||
| 11 | return fmt.Sprintf("tx9_%s_%s", goos, goarch) | ||
| 12 | } | ||
| @@ -0,0 +1,19 @@ | |||
| 1 | package selfupdate | ||
| 2 | |||
| 3 | import "testing" | ||
| 4 | |||
| 5 | func TestAssetName(t *testing.T) { | ||
| 6 | cases := []struct { | ||
| 7 | goos, goarch, want string | ||
| 8 | }{ | ||
| 9 | {"linux", "amd64", "tx9_linux_amd64"}, | ||
| 10 | {"linux", "arm64", "tx9_linux_arm64"}, | ||
| 11 | {"darwin", "amd64", "tx9_darwin_amd64"}, | ||
| 12 | {"darwin", "arm64", "tx9_darwin_arm64"}, | ||
| 13 | } | ||
| 14 | for _, c := range cases { | ||
| 15 | if got := AssetName(c.goos, c.goarch); got != c.want { | ||
| 16 | t.Errorf("AssetName(%q, %q) = %q, want %q", c.goos, c.goarch, got, c.want) | ||
| 17 | } | ||
| 18 | } | ||
| 19 | } | ||
| @@ -0,0 +1,54 @@ | |||
| 1 | package selfupdate | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "fmt" | ||
| 6 | "strings" | ||
| 7 | ) | ||
| 8 | |||
| 9 | // ParseChecksums parses a checksums.txt in the standard `sha256sum` output | ||
| 10 | // format — one "<64-hex-char-digest> <filename>" pair per line (the | ||
| 11 | // classic tool emits two spaces; a single space or tab is also accepted) | ||
| 12 | // — and returns a filename -> lowercase hex digest map. Blank lines and | ||
| 13 | // "#"-prefixed comment lines are ignored, so the release workflow is free | ||
| 14 | // to add a header. | ||
| 15 | func ParseChecksums(data []byte) (map[string]string, error) { | ||
| 16 | out := make(map[string]string) | ||
| 17 | sc := bufio.NewScanner(strings.NewReader(string(data))) | ||
| 18 | for sc.Scan() { | ||
| 19 | line := strings.TrimSpace(sc.Text()) | ||
| 20 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 21 | continue | ||
| 22 | } | ||
| 23 | fields := strings.Fields(line) | ||
| 24 | if len(fields) != 2 { | ||
| 25 | return nil, fmt.Errorf("checksums: malformed line %q (want \"<digest> <filename>\")", line) | ||
| 26 | } | ||
| 27 | digest := strings.ToLower(fields[0]) | ||
| 28 | // sha256sum -b (binary mode) prefixes the filename with "*". | ||
| 29 | name := strings.TrimPrefix(fields[1], "*") | ||
| 30 | if len(digest) != 64 || !isHex(digest) { | ||
| 31 | return nil, fmt.Errorf("checksums: %q: digest %q is not 64 hex chars", name, fields[0]) | ||
| 32 | } | ||
| 33 | if name == "" { | ||
| 34 | return nil, fmt.Errorf("checksums: malformed line %q (empty filename)", line) | ||
| 35 | } | ||
| 36 | if _, dup := out[name]; dup { | ||
| 37 | return nil, fmt.Errorf("checksums: duplicate entry for %q", name) | ||
| 38 | } | ||
| 39 | out[name] = digest | ||
| 40 | } | ||
| 41 | if err := sc.Err(); err != nil { | ||
| 42 | return nil, fmt.Errorf("checksums: %w", err) | ||
| 43 | } | ||
| 44 | return out, nil | ||
| 45 | } | ||
| 46 | |||
| 47 | func isHex(s string) bool { | ||
| 48 | for _, c := range s { | ||
| 49 | if (c < '0' || c > '9') && (c < 'a' || c > 'f') { | ||
| 50 | return false | ||
| 51 | } | ||
| 52 | } | ||
| 53 | return true | ||
| 54 | } | ||
| @@ -0,0 +1,66 @@ | |||
| 1 | package selfupdate | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 7 | |||
| 8 | func TestParseChecksums(t *testing.T) { | ||
| 9 | digestA := strings.Repeat("a", 64) | ||
| 10 | digestB := strings.Repeat("b", 64) | ||
| 11 | data := "# checksums for v0.2.0\n" + | ||
| 12 | digestA + " tx9_linux_amd64\n" + | ||
| 13 | "\n" + | ||
| 14 | digestB + " tx9_darwin_arm64\n" | ||
| 15 | |||
| 16 | sums, err := ParseChecksums([]byte(data)) | ||
| 17 | if err != nil { | ||
| 18 | t.Fatalf("ParseChecksums: %v", err) | ||
| 19 | } | ||
| 20 | if sums["tx9_linux_amd64"] != digestA { | ||
| 21 | t.Errorf("tx9_linux_amd64 = %q, want %q", sums["tx9_linux_amd64"], digestA) | ||
| 22 | } | ||
| 23 | if sums["tx9_darwin_arm64"] != digestB { | ||
| 24 | t.Errorf("tx9_darwin_arm64 = %q, want %q", sums["tx9_darwin_arm64"], digestB) | ||
| 25 | } | ||
| 26 | if len(sums) != 2 { | ||
| 27 | t.Errorf("len(sums) = %d, want 2", len(sums)) | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | func TestParseChecksumsBinaryModeStar(t *testing.T) { | ||
| 32 | digest := strings.Repeat("c", 64) | ||
| 33 | sums, err := ParseChecksums([]byte(digest + " *tx9_linux_amd64\n")) | ||
| 34 | if err != nil { | ||
| 35 | t.Fatalf("ParseChecksums: %v", err) | ||
| 36 | } | ||
| 37 | if sums["tx9_linux_amd64"] != digest { | ||
| 38 | t.Errorf("tx9_linux_amd64 = %q, want %q", sums["tx9_linux_amd64"], digest) | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | func TestParseChecksumsRejectsBadDigest(t *testing.T) { | ||
| 43 | if _, err := ParseChecksums([]byte("not-a-digest tx9_linux_amd64\n")); err == nil { | ||
| 44 | t.Fatal("ParseChecksums: want error for non-hex digest, got nil") | ||
| 45 | } | ||
| 46 | if _, err := ParseChecksums([]byte(strings.Repeat("a", 63) + " tx9_linux_amd64\n")); err == nil { | ||
| 47 | t.Fatal("ParseChecksums: want error for short digest, got nil") | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | func TestParseChecksumsRejectsMalformedLine(t *testing.T) { | ||
| 52 | if _, err := ParseChecksums([]byte("just-one-field\n")); err == nil { | ||
| 53 | t.Fatal("ParseChecksums: want error for one-field line, got nil") | ||
| 54 | } | ||
| 55 | if _, err := ParseChecksums([]byte("a b c\n")); err == nil { | ||
| 56 | t.Fatal("ParseChecksums: want error for three-field line, got nil") | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | func TestParseChecksumsRejectsDuplicate(t *testing.T) { | ||
| 61 | digest := strings.Repeat("a", 64) | ||
| 62 | data := digest + " tx9_linux_amd64\n" + digest + " tx9_linux_amd64\n" | ||
| 63 | if _, err := ParseChecksums([]byte(data)); err == nil { | ||
| 64 | t.Fatal("ParseChecksums: want error for duplicate entry, got nil") | ||
| 65 | } | ||
| 66 | } | ||
| @@ -0,0 +1,93 @@ | |||
| 1 | package selfupdate | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "fmt" | ||
| 6 | "io" | ||
| 7 | "net/http" | ||
| 8 | "os" | ||
| 9 | "strings" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // Repo is the GitHub repository release assets are published to. | ||
| 13 | const Repo = "davis7dotsh/tx9" | ||
| 14 | |||
| 15 | // ErrNoRelease indicates the repository has no published releases yet. | ||
| 16 | // GitHub's /releases/latest returns 404 in that case, which is the | ||
| 17 | // expected state until the first `v*` tag is pushed (see | ||
| 18 | // .github/workflows/release.yml) — callers should surface this as a | ||
| 19 | // clean, actionable message rather than a raw HTTP error. | ||
| 20 | var ErrNoRelease = fmt.Errorf("no releases published yet") | ||
| 21 | |||
| 22 | // Asset is one file attached to a GitHub release. | ||
| 23 | type Asset struct { | ||
| 24 | Name string `json:"name"` | ||
| 25 | BrowserDownloadURL string `json:"browser_download_url"` | ||
| 26 | } | ||
| 27 | |||
| 28 | // Release is the subset of the GitHub releases API response this package | ||
| 29 | // needs. | ||
| 30 | type Release struct { | ||
| 31 | TagName string `json:"tag_name"` | ||
| 32 | Assets []Asset `json:"assets"` | ||
| 33 | } | ||
| 34 | |||
| 35 | // FindAsset returns the asset in r named name, or false if absent. | ||
| 36 | func (r *Release) FindAsset(name string) (Asset, bool) { | ||
| 37 | for _, a := range r.Assets { | ||
| 38 | if a.Name == name { | ||
| 39 | return a, true | ||
| 40 | } | ||
| 41 | } | ||
| 42 | return Asset{}, false | ||
| 43 | } | ||
| 44 | |||
| 45 | func (r *Release) assetNames() string { | ||
| 46 | names := make([]string, len(r.Assets)) | ||
| 47 | for i, a := range r.Assets { | ||
| 48 | names[i] = a.Name | ||
| 49 | } | ||
| 50 | return strings.Join(names, ", ") | ||
| 51 | } | ||
| 52 | |||
| 53 | // FetchLatestRelease queries the GitHub API for Repo's latest release. It | ||
| 54 | // honors the GITHUB_TOKEN environment variable (if set) as a bearer | ||
| 55 | // credential on the API call only, to avoid the low unauthenticated rate | ||
| 56 | // limit — it is deliberately NOT attached to asset download requests, | ||
| 57 | // since browser_download_url responses are signed redirects that an extra | ||
| 58 | // Authorization header can break. | ||
| 59 | func FetchLatestRelease(httpClient *http.Client) (*Release, error) { | ||
| 60 | url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", Repo) | ||
| 61 | req, err := http.NewRequest(http.MethodGet, url, nil) | ||
| 62 | if err != nil { | ||
| 63 | return nil, fmt.Errorf("selfupdate: build request: %w", err) | ||
| 64 | } | ||
| 65 | req.Header.Set("User-Agent", userAgent) | ||
| 66 | req.Header.Set("Accept", "application/vnd.github+json") | ||
| 67 | if tok := os.Getenv("GITHUB_TOKEN"); tok != "" { | ||
| 68 | req.Header.Set("Authorization", "Bearer "+tok) | ||
| 69 | } | ||
| 70 | |||
| 71 | resp, err := httpClient.Do(req) | ||
| 72 | if err != nil { | ||
| 73 | return nil, fmt.Errorf("selfupdate: fetch latest release: %w", err) | ||
| 74 | } | ||
| 75 | defer resp.Body.Close() | ||
| 76 | |||
| 77 | if resp.StatusCode == http.StatusNotFound { | ||
| 78 | return nil, ErrNoRelease | ||
| 79 | } | ||
| 80 | if resp.StatusCode != http.StatusOK { | ||
| 81 | body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) | ||
| 82 | return nil, fmt.Errorf("selfupdate: GitHub API returned %s: %s", resp.Status, strings.TrimSpace(string(body))) | ||
| 83 | } | ||
| 84 | |||
| 85 | var rel Release | ||
| 86 | if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { | ||
| 87 | return nil, fmt.Errorf("selfupdate: decode release response: %w", err) | ||
| 88 | } | ||
| 89 | if rel.TagName == "" { | ||
| 90 | return nil, fmt.Errorf("selfupdate: release response missing tag_name") | ||
| 91 | } | ||
| 92 | return &rel, nil | ||
| 93 | } | ||
| @@ -0,0 +1,240 @@ | |||
| 1 | // Package selfupdate implements `tx9 upgrade` with no box argument | ||
| 2 | // (docs/tx9-cli-design.md decision 11): it queries GitHub Releases for the | ||
| 3 | // latest tagged tx9 release, compares it against the running binary's | ||
| 4 | // version, downloads and checksum-verifies the matching platform asset, | ||
| 5 | // and atomically replaces the currently running executable. | ||
| 6 | // | ||
| 7 | // Asset naming (the one invariant shared across this package, the | ||
| 8 | // Makefile's `dist` target, and .github/workflows/release.yml): | ||
| 9 | // plain, uncompressed executables named "tx9_<GOOS>_<GOARCH>" (see | ||
| 10 | // AssetName), plus a "checksums.txt" asset in `sha256sum` output format | ||
| 11 | // (see ParseChecksums). Changing this naming means changing it in all | ||
| 12 | // three places. | ||
| 13 | package selfupdate | ||
| 14 | |||
| 15 | import ( | ||
| 16 | "crypto/sha256" | ||
| 17 | "encoding/hex" | ||
| 18 | "fmt" | ||
| 19 | "io" | ||
| 20 | "net/http" | ||
| 21 | "os" | ||
| 22 | "path/filepath" | ||
| 23 | "runtime" | ||
| 24 | "time" | ||
| 25 | ) | ||
| 26 | |||
| 27 | const ( | ||
| 28 | checksumsAssetName = "checksums.txt" | ||
| 29 | userAgent = "tx9-selfupdate" | ||
| 30 | ) | ||
| 31 | |||
| 32 | // Options configures Update. | ||
| 33 | type Options struct { | ||
| 34 | // CurrentVersion is the running binary's version (internal/version.Version). | ||
| 35 | CurrentVersion string | ||
| 36 | // Force skips the dev-version guard and the already-up-to-date | ||
| 37 | // short-circuit, and re-installs even the same version. | ||
| 38 | Force bool | ||
| 39 | // GOOS/GOARCH override runtime.GOOS/GOARCH. Tests only; zero value | ||
| 40 | // means "use the running binary's platform". | ||
| 41 | GOOS, GOARCH string | ||
| 42 | // HTTPClient overrides the default client used for both the GitHub | ||
| 43 | // API call and the asset downloads. Tests only; zero value means | ||
| 44 | // "use a client with a sane timeout". | ||
| 45 | HTTPClient *http.Client | ||
| 46 | // Out receives human-readable progress lines. Defaults to io.Discard. | ||
| 47 | Out io.Writer | ||
| 48 | |||
| 49 | // execPathOverride replaces the os.Executable()+EvalSymlinks lookup. | ||
| 50 | // Tests only (unexported): Update must never overwrite the actual | ||
| 51 | // running test binary. | ||
| 52 | execPathOverride string | ||
| 53 | } | ||
| 54 | |||
| 55 | // Result summarizes a completed (or skipped) update. | ||
| 56 | type Result struct { | ||
| 57 | // Applied is false when the binary was already at the latest version | ||
| 58 | // (and Force was not set) — nothing was downloaded or replaced. | ||
| 59 | Applied bool | ||
| 60 | // FromVersion is the version that was running before Update was called. | ||
| 61 | FromVersion string | ||
| 62 | // ToVersion is the release tag_name (with leading "v" kept as published). | ||
| 63 | ToVersion string | ||
| 64 | // InstalledPath is the executable path that was replaced. Empty when | ||
| 65 | // Applied is false. | ||
| 66 | InstalledPath string | ||
| 67 | } | ||
| 68 | |||
| 69 | // ErrDevVersion is returned when CurrentVersion is "dev" (an unversioned | ||
| 70 | // `go build`/`go run`) and Force is not set — there is no meaningful | ||
| 71 | // "latest" to compare a dev build against, and self-replacing a | ||
| 72 | // developer's working binary without asking is surprising. | ||
| 73 | var ErrDevVersion = fmt.Errorf("running a dev build (no version baked in); build a release or pass --force") | ||
| 74 | |||
| 75 | // Update runs the full self-update flow described in the package doc. | ||
| 76 | func Update(opts Options) (*Result, error) { | ||
| 77 | out := opts.Out | ||
| 78 | if out == nil { | ||
| 79 | out = io.Discard | ||
| 80 | } | ||
| 81 | |||
| 82 | if opts.CurrentVersion == "" || opts.CurrentVersion == "dev" { | ||
| 83 | if !opts.Force { | ||
| 84 | return nil, ErrDevVersion | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | client := opts.HTTPClient | ||
| 89 | if client == nil { | ||
| 90 | client = &http.Client{Timeout: 30 * time.Second} | ||
| 91 | } | ||
| 92 | |||
| 93 | fmt.Fprintln(out, "tx9: checking latest release...") | ||
| 94 | rel, err := FetchLatestRelease(client) | ||
| 95 | if err != nil { | ||
| 96 | return nil, err | ||
| 97 | } | ||
| 98 | |||
| 99 | if !opts.Force && SameVersion(opts.CurrentVersion, rel.TagName) { | ||
| 100 | return &Result{Applied: false, FromVersion: opts.CurrentVersion, ToVersion: rel.TagName}, nil | ||
| 101 | } | ||
| 102 | |||
| 103 | goos, goarch := opts.GOOS, opts.GOARCH | ||
| 104 | if goos == "" { | ||
| 105 | goos = runtime.GOOS | ||
| 106 | } | ||
| 107 | if goarch == "" { | ||
| 108 | goarch = runtime.GOARCH | ||
| 109 | } | ||
| 110 | assetName := AssetName(goos, goarch) | ||
| 111 | |||
| 112 | asset, ok := rel.FindAsset(assetName) | ||
| 113 | if !ok { | ||
| 114 | return nil, fmt.Errorf("selfupdate: release %s has no asset named %q (have: %s)", rel.TagName, assetName, rel.assetNames()) | ||
| 115 | } | ||
| 116 | checksumsAsset, ok := rel.FindAsset(checksumsAssetName) | ||
| 117 | if !ok { | ||
| 118 | return nil, fmt.Errorf("selfupdate: release %s is missing %s, can't verify %q", rel.TagName, checksumsAssetName, assetName) | ||
| 119 | } | ||
| 120 | |||
| 121 | // Resolve the running executable's real path before touching the | ||
| 122 | // network for the (larger) binary asset, so a broken install layout | ||
| 123 | // fails fast instead of after a wasted download. | ||
| 124 | realPath := opts.execPathOverride | ||
| 125 | if realPath == "" { | ||
| 126 | exePath, err := os.Executable() | ||
| 127 | if err != nil { | ||
| 128 | return nil, fmt.Errorf("selfupdate: locate running executable: %w", err) | ||
| 129 | } | ||
| 130 | realPath, err = filepath.EvalSymlinks(exePath) | ||
| 131 | if err != nil { | ||
| 132 | return nil, fmt.Errorf("selfupdate: resolve running executable %q: %w", exePath, err) | ||
| 133 | } | ||
| 134 | } | ||
| 135 | |||
| 136 | fmt.Fprintf(out, "tx9: fetching checksums for %s...\n", rel.TagName) | ||
| 137 | checksumsData, err := fetchBytes(client, checksumsAsset.BrowserDownloadURL) | ||
| 138 | if err != nil { | ||
| 139 | return nil, fmt.Errorf("selfupdate: download %s: %w", checksumsAssetName, err) | ||
| 140 | } | ||
| 141 | sums, err := ParseChecksums(checksumsData) | ||
| 142 | if err != nil { | ||
| 143 | return nil, fmt.Errorf("selfupdate: parse %s: %w", checksumsAssetName, err) | ||
| 144 | } | ||
| 145 | wantDigest, ok := sums[assetName] | ||
| 146 | if !ok { | ||
| 147 | return nil, fmt.Errorf("selfupdate: %s has no checksum entry for %q", checksumsAssetName, assetName) | ||
| 148 | } | ||
| 149 | |||
| 150 | stagePath := realPath + ".new" | ||
| 151 | fmt.Fprintf(out, "tx9: downloading %s %s -> %s\n", assetName, rel.TagName, stagePath) | ||
| 152 | gotDigest, err := downloadToFile(client, asset.BrowserDownloadURL, stagePath) | ||
| 153 | if err != nil { | ||
| 154 | os.Remove(stagePath) | ||
| 155 | return nil, fmt.Errorf("selfupdate: download %s: %w", assetName, err) | ||
| 156 | } | ||
| 157 | if gotDigest != wantDigest { | ||
| 158 | os.Remove(stagePath) | ||
| 159 | return nil, fmt.Errorf("selfupdate: checksum mismatch for %s: got %s, want %s (old binary untouched)", assetName, gotDigest, wantDigest) | ||
| 160 | } | ||
| 161 | |||
| 162 | if err := os.Chmod(stagePath, 0o755); err != nil { | ||
| 163 | os.Remove(stagePath) | ||
| 164 | return nil, fmt.Errorf("selfupdate: chmod staged binary: %w (old binary untouched)", err) | ||
| 165 | } | ||
| 166 | |||
| 167 | // stagePath is a sibling of realPath (same directory), so this rename | ||
| 168 | // is same-filesystem and atomic. On Linux, renaming over the binary a | ||
| 169 | // process is currently executing is safe: the running process keeps | ||
| 170 | // its already-mapped inode; the new inode simply takes the path. | ||
| 171 | if err := os.Rename(stagePath, realPath); err != nil { | ||
| 172 | os.Remove(stagePath) | ||
| 173 | return nil, fmt.Errorf("selfupdate: install new binary at %s: %w (old binary untouched)", realPath, err) | ||
| 174 | } | ||
| 175 | |||
| 176 | fmt.Fprintf(out, "tx9: installed %s at %s\n", rel.TagName, realPath) | ||
| 177 | return &Result{ | ||
| 178 | Applied: true, | ||
| 179 | FromVersion: opts.CurrentVersion, | ||
| 180 | ToVersion: rel.TagName, | ||
| 181 | InstalledPath: realPath, | ||
| 182 | }, nil | ||
| 183 | } | ||
| 184 | |||
| 185 | // fetchBytes downloads url and returns its full body. Used for the small | ||
| 186 | // checksums.txt asset. | ||
| 187 | func fetchBytes(client *http.Client, url string) ([]byte, error) { | ||
| 188 | req, err := http.NewRequest(http.MethodGet, url, nil) | ||
| 189 | if err != nil { | ||
| 190 | return nil, err | ||
| 191 | } | ||
| 192 | req.Header.Set("User-Agent", userAgent) | ||
| 193 | |||
| 194 | resp, err := client.Do(req) | ||
| 195 | if err != nil { | ||
| 196 | return nil, err | ||
| 197 | } | ||
| 198 | defer resp.Body.Close() | ||
| 199 | |||
| 200 | if resp.StatusCode != http.StatusOK { | ||
| 201 | return nil, fmt.Errorf("GET %s: %s", url, resp.Status) | ||
| 202 | } | ||
| 203 | return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MiB is generous for a checksums file | ||
| 204 | } | ||
| 205 | |||
| 206 | // downloadToFile streams url's body to destPath (created/truncated, | ||
| 207 | // mode 0644 pending the caller's chmod) while hashing it, and returns the | ||
| 208 | // lowercase hex sha256 digest of what was written. | ||
| 209 | func downloadToFile(client *http.Client, url, destPath string) (string, error) { | ||
| 210 | req, err := http.NewRequest(http.MethodGet, url, nil) | ||
| 211 | if err != nil { | ||
| 212 | return "", err | ||
| 213 | } | ||
| 214 | req.Header.Set("User-Agent", userAgent) | ||
| 215 | |||
| 216 | resp, err := client.Do(req) | ||
| 217 | if err != nil { | ||
| 218 | return "", err | ||
| 219 | } | ||
| 220 | defer resp.Body.Close() | ||
| 221 | |||
| 222 | if resp.StatusCode != http.StatusOK { | ||
| 223 | return "", fmt.Errorf("GET %s: %s", url, resp.Status) | ||
| 224 | } | ||
| 225 | |||
| 226 | f, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) | ||
| 227 | if err != nil { | ||
| 228 | return "", err | ||
| 229 | } | ||
| 230 | defer f.Close() | ||
| 231 | |||
| 232 | h := sha256.New() | ||
| 233 | if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil { | ||
| 234 | return "", err | ||
| 235 | } | ||
| 236 | if err := f.Close(); err != nil { | ||
| 237 | return "", err | ||
| 238 | } | ||
| 239 | return hex.EncodeToString(h.Sum(nil)), nil | ||
| 240 | } | ||
| @@ -0,0 +1,216 @@ | |||
| 1 | package selfupdate | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/sha256" | ||
| 5 | "encoding/hex" | ||
| 6 | "encoding/json" | ||
| 7 | "fmt" | ||
| 8 | "net/http" | ||
| 9 | "net/http/httptest" | ||
| 10 | "os" | ||
| 11 | "path/filepath" | ||
| 12 | "strings" | ||
| 13 | "testing" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // newTestServer serves a fake GitHub releases API + asset downloads for | ||
| 17 | // one release. assetContent is the payload for the platform binary asset | ||
| 18 | // named tx9_<goos>_<goarch>; checksums.txt is generated to match it. | ||
| 19 | func newTestServer(t *testing.T, tag, goos, goarch string, assetContent []byte) *httptest.Server { | ||
| 20 | t.Helper() | ||
| 21 | assetName := AssetName(goos, goarch) | ||
| 22 | sum := sha256.Sum256(assetContent) | ||
| 23 | checksums := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), assetName) | ||
| 24 | |||
| 25 | mux := http.NewServeMux() | ||
| 26 | var srv *httptest.Server | ||
| 27 | mux.HandleFunc("/repos/"+Repo+"/releases/latest", func(w http.ResponseWriter, r *http.Request) { | ||
| 28 | rel := Release{ | ||
| 29 | TagName: tag, | ||
| 30 | Assets: []Asset{ | ||
| 31 | {Name: assetName, BrowserDownloadURL: srv.URL + "/download/" + assetName}, | ||
| 32 | {Name: checksumsAssetName, BrowserDownloadURL: srv.URL + "/download/" + checksumsAssetName}, | ||
| 33 | }, | ||
| 34 | } | ||
| 35 | w.Header().Set("Content-Type", "application/json") | ||
| 36 | _ = json.NewEncoder(w).Encode(rel) | ||
| 37 | }) | ||
| 38 | mux.HandleFunc("/download/"+assetName, func(w http.ResponseWriter, r *http.Request) { | ||
| 39 | _, _ = w.Write(assetContent) | ||
| 40 | }) | ||
| 41 | mux.HandleFunc("/download/"+checksumsAssetName, func(w http.ResponseWriter, r *http.Request) { | ||
| 42 | _, _ = w.Write([]byte(checksums)) | ||
| 43 | }) | ||
| 44 | srv = httptest.NewServer(mux) | ||
| 45 | t.Cleanup(srv.Close) | ||
| 46 | return srv | ||
| 47 | } | ||
| 48 | |||
| 49 | // apiURLClient points FetchLatestRelease's github.com/api URL at our test | ||
| 50 | // server by overriding the transport's DialContext... Instead of faking | ||
| 51 | // DNS, selfupdate.go builds the URL from the Repo constant against a fixed | ||
| 52 | // host, so tests use a small indirection: an http.Client whose Transport | ||
| 53 | // rewrites api.github.com requests to the test server. | ||
| 54 | type rewriteTransport struct { | ||
| 55 | target string | ||
| 56 | rt http.RoundTripper | ||
| 57 | } | ||
| 58 | |||
| 59 | func (rt rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { | ||
| 60 | if req.URL.Host == "api.github.com" { | ||
| 61 | req.URL.Scheme = "http" | ||
| 62 | req.URL.Host = rt.target[len("http://"):] | ||
| 63 | } | ||
| 64 | return rt.rt.RoundTrip(req) | ||
| 65 | } | ||
| 66 | |||
| 67 | func clientFor(srv *httptest.Server) *http.Client { | ||
| 68 | return &http.Client{Transport: rewriteTransport{target: srv.URL, rt: http.DefaultTransport}} | ||
| 69 | } | ||
| 70 | |||
| 71 | func TestUpdateAlreadyUpToDate(t *testing.T) { | ||
| 72 | srv := newTestServer(t, "v1.2.3", "linux", "amd64", []byte("binary contents")) | ||
| 73 | res, err := Update(Options{ | ||
| 74 | CurrentVersion: "1.2.3", | ||
| 75 | GOOS: "linux", | ||
| 76 | GOARCH: "amd64", | ||
| 77 | HTTPClient: clientFor(srv), | ||
| 78 | }) | ||
| 79 | if err != nil { | ||
| 80 | t.Fatalf("Update: %v", err) | ||
| 81 | } | ||
| 82 | if res.Applied { | ||
| 83 | t.Fatal("Update: Applied = true, want false (already up to date)") | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | func TestUpdateDevVersionRefusesWithoutForce(t *testing.T) { | ||
| 88 | _, err := Update(Options{CurrentVersion: "dev"}) | ||
| 89 | if err != ErrDevVersion { | ||
| 90 | t.Fatalf("Update: err = %v, want ErrDevVersion", err) | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | func TestUpdateDownloadsVerifiesAndReplaces(t *testing.T) { | ||
| 95 | content := []byte("new binary contents v1.2.4") | ||
| 96 | srv := newTestServer(t, "v1.2.4", "linux", "amd64", content) | ||
| 97 | |||
| 98 | dir := t.TempDir() | ||
| 99 | exePath := filepath.Join(dir, "tx9") | ||
| 100 | if err := os.WriteFile(exePath, []byte("old binary contents"), 0o755); err != nil { | ||
| 101 | t.Fatalf("seed old binary: %v", err) | ||
| 102 | } | ||
| 103 | |||
| 104 | opts := Options{ | ||
| 105 | CurrentVersion: "1.2.3", | ||
| 106 | GOOS: "linux", | ||
| 107 | GOARCH: "amd64", | ||
| 108 | HTTPClient: clientFor(srv), | ||
| 109 | execPathOverride: exePath, | ||
| 110 | } | ||
| 111 | res, err := Update(opts) | ||
| 112 | if err != nil { | ||
| 113 | t.Fatalf("Update: %v", err) | ||
| 114 | } | ||
| 115 | if !res.Applied { | ||
| 116 | t.Fatal("Update: Applied = false, want true") | ||
| 117 | } | ||
| 118 | if res.ToVersion != "v1.2.4" { | ||
| 119 | t.Errorf("Update: ToVersion = %q, want v1.2.4", res.ToVersion) | ||
| 120 | } | ||
| 121 | if res.InstalledPath != exePath { | ||
| 122 | t.Errorf("Update: InstalledPath = %q, want %q", res.InstalledPath, exePath) | ||
| 123 | } | ||
| 124 | |||
| 125 | got, err := os.ReadFile(exePath) | ||
| 126 | if err != nil { | ||
| 127 | t.Fatalf("read installed binary: %v", err) | ||
| 128 | } | ||
| 129 | if string(got) != string(content) { | ||
| 130 | t.Errorf("installed binary = %q, want %q", got, content) | ||
| 131 | } | ||
| 132 | info, err := os.Stat(exePath) | ||
| 133 | if err != nil { | ||
| 134 | t.Fatalf("stat installed binary: %v", err) | ||
| 135 | } | ||
| 136 | if info.Mode().Perm() != 0o755 { | ||
| 137 | t.Errorf("installed binary mode = %o, want 0755", info.Mode().Perm()) | ||
| 138 | } | ||
| 139 | if _, err := os.Stat(exePath + ".new"); !os.IsNotExist(err) { | ||
| 140 | t.Errorf("stage file %s.new should be gone after a successful rename", exePath) | ||
| 141 | } | ||
| 142 | } | ||
| 143 | |||
| 144 | func TestUpdateChecksumMismatchLeavesOldBinaryUntouched(t *testing.T) { | ||
| 145 | dir := t.TempDir() | ||
| 146 | exePath := filepath.Join(dir, "tx9") | ||
| 147 | original := []byte("old binary contents") | ||
| 148 | if err := os.WriteFile(exePath, original, 0o755); err != nil { | ||
| 149 | t.Fatalf("seed old binary: %v", err) | ||
| 150 | } | ||
| 151 | |||
| 152 | // A server whose checksums.txt deliberately doesn't match the asset | ||
| 153 | // it describes, to exercise the verify-before-install path. | ||
| 154 | assetName := AssetName("linux", "amd64") | ||
| 155 | badChecksum := strings.Repeat("0", 64) | ||
| 156 | mux := http.NewServeMux() | ||
| 157 | var srv *httptest.Server | ||
| 158 | mux.HandleFunc("/repos/"+Repo+"/releases/latest", func(w http.ResponseWriter, r *http.Request) { | ||
| 159 | rel := Release{ | ||
| 160 | TagName: "v1.2.4", | ||
| 161 | Assets: []Asset{ | ||
| 162 | {Name: assetName, BrowserDownloadURL: srv.URL + "/download/" + assetName}, | ||
| 163 | {Name: checksumsAssetName, BrowserDownloadURL: srv.URL + "/download/checksums.txt"}, | ||
| 164 | }, | ||
| 165 | } | ||
| 166 | w.Header().Set("Content-Type", "application/json") | ||
| 167 | _ = json.NewEncoder(w).Encode(rel) | ||
| 168 | }) | ||
| 169 | mux.HandleFunc("/download/"+assetName, func(w http.ResponseWriter, r *http.Request) { | ||
| 170 | _, _ = w.Write([]byte("new binary contents")) | ||
| 171 | }) | ||
| 172 | mux.HandleFunc("/download/checksums.txt", func(w http.ResponseWriter, r *http.Request) { | ||
| 173 | _, _ = w.Write([]byte(badChecksum + " " + assetName + "\n")) | ||
| 174 | }) | ||
| 175 | srv = httptest.NewServer(mux) | ||
| 176 | t.Cleanup(srv.Close) | ||
| 177 | |||
| 178 | _, err := Update(Options{ | ||
| 179 | CurrentVersion: "1.2.3", | ||
| 180 | GOOS: "linux", | ||
| 181 | GOARCH: "amd64", | ||
| 182 | HTTPClient: clientFor(srv), | ||
| 183 | execPathOverride: exePath, | ||
| 184 | }) | ||
| 185 | if err == nil { | ||
| 186 | t.Fatal("Update: want checksum mismatch error, got nil") | ||
| 187 | } | ||
| 188 | |||
| 189 | got, err := os.ReadFile(exePath) | ||
| 190 | if err != nil { | ||
| 191 | t.Fatalf("read binary after failed update: %v", err) | ||
| 192 | } | ||
| 193 | if string(got) != string(original) { | ||
| 194 | t.Errorf("binary was modified despite checksum mismatch: got %q, want unchanged %q", got, original) | ||
| 195 | } | ||
| 196 | if _, err := os.Stat(exePath + ".new"); !os.IsNotExist(err) { | ||
| 197 | t.Errorf("stage file %s.new should be cleaned up after a failed update", exePath) | ||
| 198 | } | ||
| 199 | } | ||
| 200 | |||
| 201 | func TestUpdateNoReleaseYet(t *testing.T) { | ||
| 202 | mux := http.NewServeMux() | ||
| 203 | mux.HandleFunc("/repos/"+Repo+"/releases/latest", func(w http.ResponseWriter, r *http.Request) { | ||
| 204 | w.WriteHeader(http.StatusNotFound) | ||
| 205 | }) | ||
| 206 | srv := httptest.NewServer(mux) | ||
| 207 | t.Cleanup(srv.Close) | ||
| 208 | |||
| 209 | _, err := Update(Options{ | ||
| 210 | CurrentVersion: "1.2.3", | ||
| 211 | HTTPClient: clientFor(srv), | ||
| 212 | }) | ||
| 213 | if err != ErrNoRelease { | ||
| 214 | t.Fatalf("Update: err = %v, want ErrNoRelease", err) | ||
| 215 | } | ||
| 216 | } | ||
| @@ -0,0 +1,73 @@ | |||
| 1 | package selfupdate | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strconv" | ||
| 5 | "strings" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // normalizeVersion strips a leading "v" (GitHub tags look like "v0.2.0"; | ||
| 9 | // internal/version.Version has no leading v) and surrounding whitespace. | ||
| 10 | func normalizeVersion(v string) string { | ||
| 11 | return strings.TrimPrefix(strings.TrimSpace(v), "v") | ||
| 12 | } | ||
| 13 | |||
| 14 | // SameVersion reports whether a and b denote the same version once | ||
| 15 | // normalized (leading "v" stripped, dotted-numeric components compared | ||
| 16 | // numerically so "0.2" == "0.2.0"). Falls back to a normalized string | ||
| 17 | // compare for anything that doesn't parse as dotted integers, so an | ||
| 18 | // unexpected tag format never panics — it just compares unequal to | ||
| 19 | // anything else unexpected. | ||
| 20 | func SameVersion(a, b string) bool { | ||
| 21 | return CompareVersions(a, b) == 0 | ||
| 22 | } | ||
| 23 | |||
| 24 | // CompareVersions compares two dotted-numeric version strings (a leading | ||
| 25 | // "v" is tolerated on either side). It returns -1 if a<b, 0 if equal, and 1 | ||
| 26 | // if a>b. Missing trailing components compare as 0 ("1.2" == "1.2.0"). | ||
| 27 | // If either side doesn't parse as dotted integers, it falls back to a | ||
| 28 | // plain string comparison of the normalized strings. | ||
| 29 | func CompareVersions(a, b string) int { | ||
| 30 | na, nb := normalizeVersion(a), normalizeVersion(b) | ||
| 31 | if na == nb { | ||
| 32 | return 0 | ||
| 33 | } | ||
| 34 | pa, oka := splitNumeric(na) | ||
| 35 | pb, okb := splitNumeric(nb) | ||
| 36 | if !oka || !okb { | ||
| 37 | return strings.Compare(na, nb) | ||
| 38 | } | ||
| 39 | for i := 0; i < len(pa) || i < len(pb); i++ { | ||
| 40 | var va, vb int | ||
| 41 | if i < len(pa) { | ||
| 42 | va = pa[i] | ||
| 43 | } | ||
| 44 | if i < len(pb) { | ||
| 45 | vb = pb[i] | ||
| 46 | } | ||
| 47 | if va != vb { | ||
| 48 | if va < vb { | ||
| 49 | return -1 | ||
| 50 | } | ||
| 51 | return 1 | ||
| 52 | } | ||
| 53 | } | ||
| 54 | return 0 | ||
| 55 | } | ||
| 56 | |||
| 57 | // splitNumeric splits a dotted version string into integer components, | ||
| 58 | // reporting false if any component isn't a non-negative integer. | ||
| 59 | func splitNumeric(v string) ([]int, bool) { | ||
| 60 | if v == "" { | ||
| 61 | return nil, false | ||
| 62 | } | ||
| 63 | parts := strings.Split(v, ".") | ||
| 64 | out := make([]int, len(parts)) | ||
| 65 | for i, p := range parts { | ||
| 66 | n, err := strconv.Atoi(p) | ||
| 67 | if err != nil || n < 0 { | ||
| 68 | return nil, false | ||
| 69 | } | ||
| 70 | out[i] = n | ||
| 71 | } | ||
| 72 | return out, true | ||
| 73 | } | ||
| @@ -0,0 +1,50 @@ | |||
| 1 | package selfupdate | ||
| 2 | |||
| 3 | import "testing" | ||
| 4 | |||
| 5 | func TestCompareVersions(t *testing.T) { | ||
| 6 | cases := []struct { | ||
| 7 | a, b string | ||
| 8 | want int | ||
| 9 | }{ | ||
| 10 | {"1.2.3", "1.2.3", 0}, | ||
| 11 | {"v1.2.3", "1.2.3", 0}, | ||
| 12 | {"1.2.3", "v1.2.3", 0}, | ||
| 13 | {"0.2.0", "0.10.0", -1}, | ||
| 14 | {"0.10.0", "0.2.0", 1}, | ||
| 15 | {"1.2", "1.2.0", 0}, | ||
| 16 | {"1.2.0", "1.2", 0}, | ||
| 17 | {"1.2.1", "1.2", 1}, | ||
| 18 | {"1.2", "1.2.1", -1}, | ||
| 19 | {"2.0.0", "1.9.9", 1}, | ||
| 20 | {"1.9.9", "2.0.0", -1}, | ||
| 21 | } | ||
| 22 | for _, c := range cases { | ||
| 23 | if got := CompareVersions(c.a, c.b); got != c.want { | ||
| 24 | t.Errorf("CompareVersions(%q, %q) = %d, want %d", c.a, c.b, got, c.want) | ||
| 25 | } | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | func TestCompareVersionsNonNumericFallsBackToStringCompare(t *testing.T) { | ||
| 30 | // Doesn't parse as dotted integers on either side (or both) -- must | ||
| 31 | // not panic, and equal normalized strings must still compare equal. | ||
| 32 | if CompareVersions("dev", "dev") != 0 { | ||
| 33 | t.Error(`CompareVersions("dev", "dev") != 0`) | ||
| 34 | } | ||
| 35 | if CompareVersions("dev", "0.1.0") == 0 { | ||
| 36 | t.Error(`CompareVersions("dev", "0.1.0") unexpectedly equal`) | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | func TestSameVersion(t *testing.T) { | ||
| 41 | if !SameVersion("v0.2.0", "0.2.0") { | ||
| 42 | t.Error(`SameVersion("v0.2.0", "0.2.0") = false, want true`) | ||
| 43 | } | ||
| 44 | if SameVersion("v0.2.0", "0.2.1") { | ||
| 45 | t.Error(`SameVersion("v0.2.0", "0.2.1") = true, want false`) | ||
| 46 | } | ||
| 47 | if SameVersion("dev", "0.2.0") { | ||
| 48 | t.Error(`SameVersion("dev", "0.2.0") = true, want false`) | ||
| 49 | } | ||
| 50 | } | ||
| @@ -0,0 +1,138 @@ | |||
| 1 | // Package state manages tx9's on-host state directory (~/.tx9). | ||
| 2 | package state | ||
| 3 | |||
| 4 | import ( | ||
| 5 | "bufio" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "sort" | ||
| 10 | "strings" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // Dir returns ~/.tx9, creating it (mode 0700) if absent. | ||
| 14 | func Dir() (string, error) { | ||
| 15 | home, err := os.UserHomeDir() | ||
| 16 | if err != nil { | ||
| 17 | return "", fmt.Errorf("state: dir: %w", err) | ||
| 18 | } | ||
| 19 | dir := filepath.Join(home, ".tx9") | ||
| 20 | if err := os.MkdirAll(dir, 0o700); err != nil { | ||
| 21 | return "", fmt.Errorf("state: dir: %w", err) | ||
| 22 | } | ||
| 23 | return dir, nil | ||
| 24 | } | ||
| 25 | |||
| 26 | // BoxesDir returns ~/.tx9/boxes, creating it if absent. | ||
| 27 | func BoxesDir() (string, error) { | ||
| 28 | dir, err := Dir() | ||
| 29 | if err != nil { | ||
| 30 | return "", err | ||
| 31 | } | ||
| 32 | d := filepath.Join(dir, "boxes") | ||
| 33 | if err := os.MkdirAll(d, 0o700); err != nil { | ||
| 34 | return "", fmt.Errorf("state: boxesDir: %w", err) | ||
| 35 | } | ||
| 36 | return d, nil | ||
| 37 | } | ||
| 38 | |||
| 39 | // LocksDir returns ~/.tx9/locks, creating it if absent. | ||
| 40 | func LocksDir() (string, error) { | ||
| 41 | dir, err := Dir() | ||
| 42 | if err != nil { | ||
| 43 | return "", err | ||
| 44 | } | ||
| 45 | d := filepath.Join(dir, "locks") | ||
| 46 | if err := os.MkdirAll(d, 0o700); err != nil { | ||
| 47 | return "", fmt.Errorf("state: locksDir: %w", err) | ||
| 48 | } | ||
| 49 | return d, nil | ||
| 50 | } | ||
| 51 | |||
| 52 | // BoxEnvPath returns ~/.tx9/boxes/<name>.env. | ||
| 53 | func BoxEnvPath(name string) (string, error) { | ||
| 54 | dir, err := BoxesDir() | ||
| 55 | if err != nil { | ||
| 56 | return "", err | ||
| 57 | } | ||
| 58 | return filepath.Join(dir, name+".env"), nil | ||
| 59 | } | ||
| 60 | |||
| 61 | // LockPath returns ~/.tx9/locks/<name>.lock. | ||
| 62 | func LockPath(name string) (string, error) { | ||
| 63 | dir, err := LocksDir() | ||
| 64 | if err != nil { | ||
| 65 | return "", err | ||
| 66 | } | ||
| 67 | return filepath.Join(dir, name+".lock"), nil | ||
| 68 | } | ||
| 69 | |||
| 70 | // ReadBoxEnv reads a KEY=VALUE env file into a map. Returns an empty map | ||
| 71 | // (no error) if the file does not exist. | ||
| 72 | func ReadBoxEnv(name string) (map[string]string, error) { | ||
| 73 | path, err := BoxEnvPath(name) | ||
| 74 | if err != nil { | ||
| 75 | return nil, err | ||
| 76 | } | ||
| 77 | f, err := os.Open(path) | ||
| 78 | if err != nil { | ||
| 79 | if os.IsNotExist(err) { | ||
| 80 | return map[string]string{}, nil | ||
| 81 | } | ||
| 82 | return nil, fmt.Errorf("state: readBoxEnv: %w", err) | ||
| 83 | } | ||
| 84 | defer f.Close() | ||
| 85 | |||
| 86 | env := map[string]string{} | ||
| 87 | scanner := bufio.NewScanner(f) | ||
| 88 | for scanner.Scan() { | ||
| 89 | line := strings.TrimSpace(scanner.Text()) | ||
| 90 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 91 | continue | ||
| 92 | } | ||
| 93 | k, v, ok := strings.Cut(line, "=") | ||
| 94 | if !ok { | ||
| 95 | continue | ||
| 96 | } | ||
| 97 | env[k] = v | ||
| 98 | } | ||
| 99 | if err := scanner.Err(); err != nil { | ||
| 100 | return nil, fmt.Errorf("state: readBoxEnv: %w", err) | ||
| 101 | } | ||
| 102 | return env, nil | ||
| 103 | } | ||
| 104 | |||
| 105 | // WriteBoxEnv writes a KEY=VALUE env file (mode 0600), overwriting any | ||
| 106 | // existing file. | ||
| 107 | func WriteBoxEnv(name string, env map[string]string) error { | ||
| 108 | path, err := BoxEnvPath(name) | ||
| 109 | if err != nil { | ||
| 110 | return err | ||
| 111 | } | ||
| 112 | keys := make([]string, 0, len(env)) | ||
| 113 | for k := range env { | ||
| 114 | keys = append(keys, k) | ||
| 115 | } | ||
| 116 | sort.Strings(keys) | ||
| 117 | |||
| 118 | var b strings.Builder | ||
| 119 | for _, k := range keys { | ||
| 120 | fmt.Fprintf(&b, "%s=%s\n", k, env[k]) | ||
| 121 | } | ||
| 122 | if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { | ||
| 123 | return fmt.Errorf("state: writeBoxEnv: %w", err) | ||
| 124 | } | ||
| 125 | return nil | ||
| 126 | } | ||
| 127 | |||
| 128 | // RemoveBoxEnv deletes ~/.tx9/boxes/<name>.env, if present. | ||
| 129 | func RemoveBoxEnv(name string) error { | ||
| 130 | path, err := BoxEnvPath(name) | ||
| 131 | if err != nil { | ||
| 132 | return err | ||
| 133 | } | ||
| 134 | if err := os.Remove(path); err != nil && !os.IsNotExist(err) { | ||
| 135 | return fmt.Errorf("state: removeBoxEnv: %w", err) | ||
| 136 | } | ||
| 137 | return nil | ||
| 138 | } | ||
| @@ -0,0 +1,19 @@ | |||
| 1 | // Package token mints executor bearer tokens. | ||
| 2 | package token | ||
| 3 | |||
| 4 | import ( | ||
| 5 | "crypto/rand" | ||
| 6 | "encoding/hex" | ||
| 7 | "fmt" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // Mint returns a 256-bit random token, hex-encoded (64 chars) — matches | ||
| 11 | // `head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'` from the bash | ||
| 12 | // prototype (dossier §2.1). | ||
| 13 | func Mint() (string, error) { | ||
| 14 | buf := make([]byte, 32) | ||
| 15 | if _, err := rand.Read(buf); err != nil { | ||
| 16 | return "", fmt.Errorf("token: mint: %w", err) | ||
| 17 | } | ||
| 18 | return hex.EncodeToString(buf), nil | ||
| 19 | } | ||
| @@ -0,0 +1,25 @@ | |||
| 1 | package token | ||
| 2 | |||
| 3 | import "testing" | ||
| 4 | |||
| 5 | func TestMint(t *testing.T) { | ||
| 6 | tok, err := Mint() | ||
| 7 | if err != nil { | ||
| 8 | t.Fatalf("Mint: %v", err) | ||
| 9 | } | ||
| 10 | if len(tok) != 64 { | ||
| 11 | t.Fatalf("Mint: want 64 hex chars, got %d (%q)", len(tok), tok) | ||
| 12 | } | ||
| 13 | for _, c := range tok { | ||
| 14 | if (c < '0' || c > '9') && (c < 'a' || c > 'f') { | ||
| 15 | t.Fatalf("Mint: non-hex char %q in %q", c, tok) | ||
| 16 | } | ||
| 17 | } | ||
| 18 | tok2, err := Mint() | ||
| 19 | if err != nil { | ||
| 20 | t.Fatalf("Mint: %v", err) | ||
| 21 | } | ||
| 22 | if tok == tok2 { | ||
| 23 | t.Fatalf("Mint: two calls returned the same token") | ||
| 24 | } | ||
| 25 | } | ||
| @@ -0,0 +1,6 @@ | |||
| 1 | // Package version holds the CLI's build-time version string. | ||
| 2 | package version | ||
| 3 | |||
| 4 | // Version is set via -ldflags at build time (see Makefile). Defaults to | ||
| 5 | // "dev" for `go run`/unversioned builds. | ||
| 6 | var Version = "dev" | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | // Command tx9 creates and manages hermes boxes (see docs/tx9-cli-design.md). | ||
| 2 | package main | ||
| 3 | |||
| 4 | import ( | ||
| 5 | "os" | ||
| 6 | |||
| 7 | "github.com/davis7dotsh/tx9/internal/cli" | ||
| 8 | ) | ||
| 9 | |||
| 10 | func main() { | ||
| 11 | os.Exit(cli.Run(os.Args, BuildContext)) | ||
| 12 | } | ||
| @@ -1,9 +0,0 @@ | |||
| 1 | [Unit] | ||
| 2 | Description=Automount CIFS backup vault for tx9 | ||
| 3 | |||
| 4 | [Automount] | ||
| 5 | Where=/mnt/davis-vault/tx9 | ||
| 6 | TimeoutIdleSec=600 | ||
| 7 | |||
| 8 | [Install] | ||
| 9 | WantedBy=multi-user.target | ||
| @@ -1,10 +0,0 @@ | |||
| 1 | [Unit] | ||
| 2 | Description=CIFS backup vault for tx9 | ||
| 3 | |||
| 4 | [Mount] | ||
| 5 | # Replace with the real CIFS share. The credentials file should contain | ||
| 6 | # `username=...` / `password=...` lines, mode 0600, owned by root. | ||
| 7 | What=//nas.example.invalid/share | ||
| 8 | Where=/mnt/davis-vault/tx9 | ||
| 9 | Type=cifs | ||
| 10 | Options=credentials=/etc/tx9/cifs-credentials,uid=tx9,gid=tx9,file_mode=0600,dir_mode=0700,vers=3.0,_netdev | ||
| @@ -1,16 +0,0 @@ | |||
| 1 | [Unit] | ||
| 2 | Description=Prune retained backups for tx9 agent box %i | ||
| 3 | After=tx9-backup@%i.service | ||
| 4 | |||
| 5 | [Service] | ||
| 6 | Type=oneshot | ||
| 7 | User=tx9 | ||
| 8 | Group=tx9 | ||
| 9 | UMask=0077 | ||
| 10 | NoNewPrivileges=true | ||
| 11 | ProtectHome=true | ||
| 12 | ProtectSystem=strict | ||
| 13 | PrivateTmp=true | ||
| 14 | ReadWritePaths=/opt/tx9 /var/backups/tx9 /var/lib/tx9 /etc/tx9 | ||
| 15 | ExecStart=/opt/tx9/ops/tx9-backup-prune %i | ||
| 16 | TimeoutStartSec=5min | ||
| @@ -1,10 +0,0 @@ | |||
| 1 | [Unit] | ||
| 2 | Description=Scheduled backup retention prune for tx9 agent box %i | ||
| 3 | |||
| 4 | [Timer] | ||
| 5 | OnCalendar=*-*-* 04:00:00 | ||
| 6 | RandomizedDelaySec=30min | ||
| 7 | Persistent=true | ||
| 8 | |||
| 9 | [Install] | ||
| 10 | WantedBy=timers.target | ||
| @@ -1,18 +0,0 @@ | |||
| 1 | [Unit] | ||
| 2 | Description=Encrypted backup for tx9 agent box %i | ||
| 3 | After=tx9-box@%i.service | ||
| 4 | |||
| 5 | [Service] | ||
| 6 | Type=oneshot | ||
| 7 | User=tx9 | ||
| 8 | Group=tx9 | ||
| 9 | SupplementaryGroups=kvm | ||
| 10 | UMask=0077 | ||
| 11 | NoNewPrivileges=true | ||
| 12 | ProtectHome=true | ||
| 13 | ProtectSystem=strict | ||
| 14 | PrivateTmp=true | ||
| 15 | ReadWritePaths=/opt/tx9 /var/backups/tx9 /var/lib/tx9 /etc/tx9 | ||
| 16 | LoadCredential=backup-passphrase:/etc/tx9/backup-passphrase | ||
| 17 | ExecStart=/opt/tx9/ops/tx9-host backup %i | ||
| 18 | TimeoutStartSec=2h | ||
| @@ -1,10 +0,0 @@ | |||
| 1 | [Unit] | ||
| 2 | Description=Scheduled encrypted backup for tx9 agent box %i | ||
| 3 | |||
| 4 | [Timer] | ||
| 5 | OnCalendar=*-*-* 03:15:00 | ||
| 6 | RandomizedDelaySec=30min | ||
| 7 | Persistent=true | ||
| 8 | |||
| 9 | [Install] | ||
| 10 | WantedBy=timers.target | ||
| @@ -1,26 +0,0 @@ | |||
| 1 | [Unit] | ||
| 2 | Description=tx9 agent box %i | ||
| 3 | After=network-online.target | ||
| 4 | Wants=network-online.target | ||
| 5 | |||
| 6 | [Service] | ||
| 7 | Type=simple | ||
| 8 | User=tx9 | ||
| 9 | Group=tx9 | ||
| 10 | SupplementaryGroups=kvm | ||
| 11 | UMask=0077 | ||
| 12 | NoNewPrivileges=true | ||
| 13 | ProtectHome=true | ||
| 14 | ProtectSystem=strict | ||
| 15 | PrivateTmp=true | ||
| 16 | ReadWritePaths=/opt/tx9 /var/backups/tx9 /var/lib/tx9 /etc/tx9 | ||
| 17 | ExecStart=/opt/tx9/ops/tx9-host supervise %i | ||
| 18 | ExecStop=-/bin/kill -TERM $MAINPID | ||
| 19 | ExecStop=/opt/tx9/ops/tx9-host stop %i | ||
| 20 | Restart=on-failure | ||
| 21 | RestartSec=5s | ||
| 22 | TimeoutStartSec=10min | ||
| 23 | TimeoutStopSec=2min | ||
| 24 | |||
| 25 | [Install] | ||
| 26 | WantedBy=multi-user.target | ||
| @@ -1,17 +0,0 @@ | |||
| 1 | [Unit] | ||
| 2 | Description=Verify tx9 agent box %i | ||
| 3 | After=tx9-box@%i.service | ||
| 4 | |||
| 5 | [Service] | ||
| 6 | Type=oneshot | ||
| 7 | User=tx9 | ||
| 8 | Group=tx9 | ||
| 9 | SupplementaryGroups=kvm | ||
| 10 | UMask=0077 | ||
| 11 | NoNewPrivileges=true | ||
| 12 | ProtectHome=true | ||
| 13 | ProtectSystem=strict | ||
| 14 | PrivateTmp=true | ||
| 15 | ReadWritePaths=/opt/tx9 /var/backups/tx9 /var/lib/tx9 /etc/tx9 | ||
| 16 | ExecStart=/opt/tx9/ops/tx9-host health %i | ||
| 17 | TimeoutStartSec=3min | ||
| @@ -1,10 +0,0 @@ | |||
| 1 | [Unit] | ||
| 2 | Description=Periodic health check for tx9 agent box %i | ||
| 3 | |||
| 4 | [Timer] | ||
| 5 | OnBootSec=5min | ||
| 6 | OnUnitActiveSec=5min | ||
| 7 | Persistent=true | ||
| 8 | |||
| 9 | [Install] | ||
| 10 | WantedBy=timers.target | ||
| @@ -1,70 +0,0 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Bounds backup growth: without this, /var/backups/tx9 and any configured | ||
| 3 | # TX9_NAS_DIR grow unbounded forever (retention is otherwise left entirely to | ||
| 4 | # the operator, per docs/nexus-operations.md). | ||
| 5 | set -euo pipefail | ||
| 6 | |||
| 7 | requested_name="${1:-}" | ||
| 8 | [[ "$requested_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || { | ||
| 9 | echo "usage: tx9-backup-prune <box>" >&2 | ||
| 10 | exit 2 | ||
| 11 | } | ||
| 12 | readonly requested_name | ||
| 13 | |||
| 14 | TX9_CONFIG="${TX9_CONFIG:-/etc/tx9/tx9.conf}" | ||
| 15 | [[ ! -r "$TX9_CONFIG" ]] || { # shellcheck disable=SC1090 | ||
| 16 | source "$TX9_CONFIG" | ||
| 17 | } | ||
| 18 | box_config="${TX9_BOX_CONFIG_DIR:-/etc/tx9/boxes}/${requested_name}.conf" | ||
| 19 | [[ ! -r "$box_config" ]] || { # shellcheck disable=SC1090 | ||
| 20 | source "$box_config" | ||
| 21 | } | ||
| 22 | name="$requested_name" | ||
| 23 | |||
| 24 | destination="${TX9_BACKUP_DIR:-/var/backups/tx9}" | ||
| 25 | # Always keep at least this many of the newest backups, regardless of age. | ||
| 26 | keep_count="${TX9_BACKUP_RETAIN_COUNT:-14}" | ||
| 27 | # Optional: additionally never keep a backup older than this many days, even | ||
| 28 | # if it's within the most recent TX9_BACKUP_RETAIN_COUNT. Empty = no cap. | ||
| 29 | keep_days="${TX9_BACKUP_RETAIN_DAYS:-}" | ||
| 30 | |||
| 31 | [[ "$keep_count" =~ ^[0-9]+$ ]] || { | ||
| 32 | echo "TX9_BACKUP_RETAIN_COUNT must be a non-negative integer" >&2; exit 2; | ||
| 33 | } | ||
| 34 | [[ -z "$keep_days" || "$keep_days" =~ ^[0-9]+$ ]] || { | ||
| 35 | echo "TX9_BACKUP_RETAIN_DAYS must be empty or a non-negative integer" >&2; exit 2; | ||
| 36 | } | ||
| 37 | |||
| 38 | prune_dir() { | ||
| 39 | local dir="$1" cutoff="" entries=() total index=0 base stamp keep | ||
| 40 | if [[ ! -d "$dir" ]]; then | ||
| 41 | echo "skipping prune for missing directory: $dir" >&2 | ||
| 42 | return 0 | ||
| 43 | fi | ||
| 44 | # Backup filenames are "<name>-<YYYYMMDD-HHMMSS>Z.tar.gz.gpg"; the fixed- | ||
| 45 | # width zero-padded timestamp sorts lexicographically in chronological | ||
| 46 | # order, so plain `sort` is enough — no date parsing needed for ordering. | ||
| 47 | mapfile -t entries < <( | ||
| 48 | find "$dir" -maxdepth 1 -type f -name "${name}-*.tar.gz.gpg" -printf '%f\n' 2>/dev/null | sort | ||
| 49 | ) | ||
| 50 | total="${#entries[@]}" | ||
| 51 | [[ "$total" -gt 0 ]] || return 0 | ||
| 52 | [[ -z "$keep_days" ]] || cutoff="$(date -u -d "-${keep_days} days" +%Y%m%d-%H%M%SZ)" | ||
| 53 | for base in "${entries[@]}"; do | ||
| 54 | index=$((index + 1)) | ||
| 55 | keep=1 | ||
| 56 | [[ "$((total - index))" -lt "$keep_count" ]] || keep=0 | ||
| 57 | if [[ "$keep" == 1 && -n "$cutoff" ]]; then | ||
| 58 | stamp="${base#"${name}"-}" | ||
| 59 | stamp="${stamp%.tar.gz.gpg}" | ||
| 60 | [[ "$stamp" > "$cutoff" || "$stamp" == "$cutoff" ]] || keep=0 | ||
| 61 | fi | ||
| 62 | [[ "$keep" == 1 ]] || { | ||
| 63 | echo "pruning $dir/$base" | ||
| 64 | rm -f -- "$dir/$base" "$dir/${base}.sha256" | ||
| 65 | } | ||
| 66 | done | ||
| 67 | } | ||
| 68 | |||
| 69 | prune_dir "$destination" | ||
| 70 | [[ -z "${TX9_NAS_DIR:-}" ]] || prune_dir "$TX9_NAS_DIR" | ||
| @@ -1,106 +0,0 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | requested_name="${2:-}" | ||
| 5 | [[ "$requested_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || { | ||
| 6 | echo "usage: tx9-host {start|stop|supervise|health|backup} <box>" >&2 | ||
| 7 | exit 2 | ||
| 8 | } | ||
| 9 | readonly requested_name | ||
| 10 | |||
| 11 | TX9_CONFIG="${TX9_CONFIG:-/etc/tx9/tx9.conf}" | ||
| 12 | [[ ! -r "$TX9_CONFIG" ]] || { # shellcheck disable=SC1090 | ||
| 13 | source "$TX9_CONFIG" | ||
| 14 | } | ||
| 15 | box_config="${TX9_BOX_CONFIG_DIR:-/etc/tx9/boxes}/${requested_name}.conf" | ||
| 16 | [[ ! -r "$box_config" ]] || { # shellcheck disable=SC1090 | ||
| 17 | source "$box_config" | ||
| 18 | } | ||
| 19 | name="$requested_name" | ||
| 20 | |||
| 21 | root="${TX9_ROOT:-/opt/tx9}" | ||
| 22 | box="$root/box" | ||
| 23 | [[ -x "$box" ]] || { echo "tx9 box CLI is not executable: $box" >&2; exit 1; } | ||
| 24 | |||
| 25 | require_active() { | ||
| 26 | local status | ||
| 27 | status="$(smolvm machine status --name "$name" 2>/dev/null)" || { | ||
| 28 | echo "tx9 box '$name' is missing; scheduled operation will not start it" >&2 | ||
| 29 | return 1 | ||
| 30 | } | ||
| 31 | grep -q ': running' <<<"$status" || { | ||
| 32 | echo "tx9 box '$name' is inactive; scheduled operation will not start it" >&2 | ||
| 33 | return 1 | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | case "${1:-}" in | ||
| 38 | start) exec "$box" start "$name" ;; | ||
| 39 | stop) exec "$box" stop "$name" ;; | ||
| 40 | supervise) | ||
| 41 | interval="${TX9_SUPERVISE_INTERVAL_SECONDS:-20}" | ||
| 42 | [[ "$interval" =~ ^[1-9][0-9]*$ && "$interval" -le 300 ]] || { | ||
| 43 | echo "TX9_SUPERVISE_INTERVAL_SECONDS must be 1-300" >&2; exit 2; | ||
| 44 | } | ||
| 45 | stopping=0 | ||
| 46 | sleeper="" | ||
| 47 | stop_supervisor() { | ||
| 48 | stopping=1 | ||
| 49 | [[ -z "$sleeper" ]] || kill "$sleeper" 2>/dev/null || true | ||
| 50 | } | ||
| 51 | trap stop_supervisor HUP INT TERM | ||
| 52 | "$box" start "$name" | ||
| 53 | while [[ "$stopping" == 0 ]]; do | ||
| 54 | if [[ "${TX9_SUPERVISE_ONCE:-0}" != 1 ]]; then | ||
| 55 | sleep "$interval" & | ||
| 56 | sleeper=$! | ||
| 57 | wait "$sleeper" || true | ||
| 58 | sleeper="" | ||
| 59 | [[ "$stopping" == 0 ]] || break | ||
| 60 | fi | ||
| 61 | [[ "$stopping" == 0 ]] || break | ||
| 62 | if ! smolvm machine status --name "$name" 2>/dev/null | grep -q ': running'; then | ||
| 63 | [[ "$stopping" == 0 ]] || break | ||
| 64 | echo "tx9 box '$name' stopped unexpectedly; restarting" >&2 | ||
| 65 | "$box" start "$name" | ||
| 66 | fi | ||
| 67 | [[ "${TX9_SUPERVISE_ONCE:-0}" != 1 ]] || break | ||
| 68 | done | ||
| 69 | trap - HUP INT TERM | ||
| 70 | ;; | ||
| 71 | health) require_active; exec "$box" doctor "$name" ;; | ||
| 72 | backup) | ||
| 73 | require_active | ||
| 74 | destination="${TX9_BACKUP_DIR:-/var/backups/tx9}" | ||
| 75 | credential="${BOX_PASSPHRASE_FILE:-${CREDENTIALS_DIRECTORY:+${CREDENTIALS_DIRECTORY}/backup-passphrase}}" | ||
| 76 | [[ -n "$credential" && -r "$credential" ]] || { | ||
| 77 | echo "backup passphrase credential is unavailable" >&2; exit 1; | ||
| 78 | } | ||
| 79 | mkdir -p "$destination" | ||
| 80 | stamp="$(date -u +%Y%m%d-%H%M%SZ)" | ||
| 81 | base="${name}-${stamp}.tar.gz.gpg" | ||
| 82 | partial="$destination/${base}.partial" | ||
| 83 | final="$destination/$base" | ||
| 84 | checksum_partial="$destination/${base}.sha256.partial" | ||
| 85 | nas_partial="" | ||
| 86 | nas_checksum_partial="" | ||
| 87 | trap 'rm -f -- "$partial" "$checksum_partial" ${nas_partial:+"$nas_partial"} ${nas_checksum_partial:+"$nas_checksum_partial"}' EXIT HUP INT TERM | ||
| 88 | BOX_PASSPHRASE_FILE="$credential" "$box" save "$name" "$partial" | ||
| 89 | (cd "$destination" && sha256sum "${base}.partial" | sed "s/${base}\.partial$/${base}/") >"$checksum_partial" | ||
| 90 | mv "$partial" "$final" | ||
| 91 | mv "$checksum_partial" "$destination/${base}.sha256" | ||
| 92 | if [[ -n "${TX9_NAS_DIR:-}" ]]; then | ||
| 93 | mountpoint -q "$TX9_NAS_DIR" || { | ||
| 94 | echo "local backup retained, but NAS is not mounted: $TX9_NAS_DIR" >&2; exit 1; | ||
| 95 | } | ||
| 96 | nas_partial="$TX9_NAS_DIR/${base}.partial" | ||
| 97 | nas_checksum_partial="$TX9_NAS_DIR/${base}.sha256.partial" | ||
| 98 | cp "$final" "$nas_partial" | ||
| 99 | cp "$destination/${base}.sha256" "$nas_checksum_partial" | ||
| 100 | mv "$nas_partial" "$TX9_NAS_DIR/$base" | ||
| 101 | mv "$nas_checksum_partial" "$TX9_NAS_DIR/${base}.sha256" | ||
| 102 | fi | ||
| 103 | trap - EXIT HUP INT TERM | ||
| 104 | ;; | ||
| 105 | *) echo "usage: tx9-host {start|stop|supervise|health|backup} <box>" >&2; exit 2 ;; | ||
| 106 | esac | ||
| @@ -331,9 +331,25 @@ main() { · 330 unmodified lines | |||
| 331 | PATH="$NODE_PREFIX/bin:$UV_DIR:$PATH" "$OPT/bin/hb" versions || true | 331 | 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. | ||
| 337 | tools_only() { | ||
| 338 | base_os | ||
| 339 | make_agent | ||
| 340 | install_node_uv | ||
| 341 | install_claude | ||
| 342 | install_codex | ||
| 343 | install_hermes | ||
| 344 | install_executor | ||
| 345 | slim | ||
| 346 | log "tool provisioning complete" | ||
| 347 | } | ||
| 348 | |||
| 334 | if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then | 349 | if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then |
| 335 | case "${1:-full}" in | 350 | 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 | esac | 355 | esac |
| @@ -0,0 +1,114 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # tx9 installer -- served by https://tx9.davis7.sh/install (decision 10, | ||
| 3 | # docs/tx9-cli-design.md). | ||
| 4 | # | ||
| 5 | # curl -fsSL https://tx9.davis7.sh/install | sh | ||
| 6 | # | ||
| 7 | # Detects OS/arch, downloads the matching tx9 release binary + its | ||
| 8 | # checksums.txt from the latest GitHub release, verifies the checksum, and | ||
| 9 | # installs to ~/.local/bin/tx9 (override with TX9_INSTALL_DIR). | ||
| 10 | # | ||
| 11 | # Asset naming is the one invariant this script shares with | ||
| 12 | # internal/selfupdate.AssetName, the Makefile's `dist` target, and | ||
| 13 | # .github/workflows/release.yml: plain, uncompressed executables named | ||
| 14 | # tx9_<os>_<arch>, verified against a checksums.txt release asset in | ||
| 15 | # `sha256sum` output format ("<64-hex-digest> <filename>" per line). | ||
| 16 | # | ||
| 17 | # Deliberately plain POSIX sh + curl: no jq, no GitHub API call -- the | ||
| 18 | # stable /releases/latest/download/<asset> URL shape resolves straight to | ||
| 19 | # the current release's asset without needing to parse JSON. | ||
| 20 | set -eu | ||
| 21 | |||
| 22 | REPO="davis7dotsh/tx9" | ||
| 23 | INSTALL_DIR="${TX9_INSTALL_DIR:-$HOME/.local/bin}" | ||
| 24 | |||
| 25 | log() { | ||
| 26 | printf 'tx9-install: %s\n' "$*" >&2 | ||
| 27 | } | ||
| 28 | |||
| 29 | die() { | ||
| 30 | log "$*" | ||
| 31 | exit 1 | ||
| 32 | } | ||
| 33 | |||
| 34 | need() { | ||
| 35 | command -v "$1" >/dev/null 2>&1 || die "'$1' is required to install tx9" | ||
| 36 | } | ||
| 37 | |||
| 38 | detect_os() { | ||
| 39 | case "$(uname -s)" in | ||
| 40 | Linux) echo linux ;; | ||
| 41 | Darwin) echo darwin ;; | ||
| 42 | *) die "unsupported OS: $(uname -s) (tx9 currently ships Linux and macOS binaries)" ;; | ||
| 43 | esac | ||
| 44 | } | ||
| 45 | |||
| 46 | detect_arch() { | ||
| 47 | case "$(uname -m)" in | ||
| 48 | x86_64 | amd64) echo amd64 ;; | ||
| 49 | aarch64 | arm64) echo arm64 ;; | ||
| 50 | *) die "unsupported architecture: $(uname -m) (tx9 currently ships amd64 and arm64 binaries)" ;; | ||
| 51 | esac | ||
| 52 | } | ||
| 53 | |||
| 54 | need curl | ||
| 55 | need uname | ||
| 56 | need mktemp | ||
| 57 | need mkdir | ||
| 58 | need chmod | ||
| 59 | need mv | ||
| 60 | need awk | ||
| 61 | |||
| 62 | os=$(detect_os) | ||
| 63 | arch=$(detect_arch) | ||
| 64 | asset="tx9_${os}_${arch}" | ||
| 65 | |||
| 66 | base_url="https://github.com/${REPO}/releases/latest/download" | ||
| 67 | asset_url="${base_url}/${asset}" | ||
| 68 | checksums_url="${base_url}/checksums.txt" | ||
| 69 | |||
| 70 | tmpdir=$(mktemp -d) | ||
| 71 | trap 'rm -rf "$tmpdir"' EXIT INT TERM HUP | ||
| 72 | |||
| 73 | log "downloading ${asset}..." | ||
| 74 | if ! curl -fsSL "$asset_url" -o "$tmpdir/$asset"; then | ||
| 75 | die "download failed: $asset_url (no release published yet, or an unsupported platform)" | ||
| 76 | fi | ||
| 77 | |||
| 78 | log "downloading checksums.txt..." | ||
| 79 | if ! curl -fsSL "$checksums_url" -o "$tmpdir/checksums.txt"; then | ||
| 80 | die "download failed: $checksums_url" | ||
| 81 | fi | ||
| 82 | |||
| 83 | expected=$(awk -v want="$asset" '$2 == want { print $1; found=1 } END { if (!found) exit 1 }' "$tmpdir/checksums.txt") \ | ||
| 84 | || die "no checksum entry for ${asset} in checksums.txt" | ||
| 85 | |||
| 86 | if command -v sha256sum >/dev/null 2>&1; then | ||
| 87 | actual=$(sha256sum "$tmpdir/$asset" | awk '{ print $1 }') | ||
| 88 | elif command -v shasum >/dev/null 2>&1; then | ||
| 89 | actual=$(shasum -a 256 "$tmpdir/$asset" | awk '{ print $1 }') | ||
| 90 | else | ||
| 91 | die "neither sha256sum nor shasum found; cannot verify ${asset} (install them, or download and verify manually)" | ||
| 92 | fi | ||
| 93 | |||
| 94 | [ "$actual" = "$expected" ] || die "checksum mismatch for ${asset}: got ${actual}, want ${expected} (not installing)" | ||
| 95 | |||
| 96 | mkdir -p "$INSTALL_DIR" | ||
| 97 | chmod 0755 "$tmpdir/$asset" | ||
| 98 | mv "$tmpdir/$asset" "$INSTALL_DIR/tx9" | ||
| 99 | |||
| 100 | log "installed tx9 to ${INSTALL_DIR}/tx9" | ||
| 101 | |||
| 102 | case ":${PATH}:" in | ||
| 103 | *":${INSTALL_DIR}:"*) ;; | ||
| 104 | *) | ||
| 105 | log "note: ${INSTALL_DIR} is not on your PATH. Add it to your shell profile, e.g.:" | ||
| 106 | log " export PATH=\"${INSTALL_DIR}:\$PATH\"" | ||
| 107 | ;; | ||
| 108 | esac | ||
| 109 | |||
| 110 | if version=$("$INSTALL_DIR/tx9" version 2>/dev/null); then | ||
| 111 | log "tx9 ${version} ready" | ||
| 112 | else | ||
| 113 | log "installed, but 'tx9 version' did not run cleanly -- check ${INSTALL_DIR}/tx9 manually" | ||
| 114 | fi | ||
| @@ -1,194 +0,0 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Coverage for box subcommands that previously had zero test coverage: | ||
| 3 | # ls, rm, stop, start, enter, plus the _assign_port exhaustion path and | ||
| 4 | # control-character archive rejection (the latter lives in | ||
| 5 | # tests/lifecycle-smoke.sh, alongside the rest of the archive-safety suite). | ||
| 6 | set -euo pipefail | ||
| 7 | |||
| 8 | # shellcheck source=tests/lib.sh | ||
| 9 | source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" | ||
| 10 | |||
| 11 | # --- box ls (empty registry) ------------------------------------------- | ||
| 12 | ls_empty_repo="$tmp/ls-empty-repo" | ||
| 13 | make_repo "$ls_empty_repo" | ||
| 14 | out="$(PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/ls-empty.calls" \ | ||
| 15 | SMOLVM_STATE_DIR="$tmp/ls-empty-state" "$ls_empty_repo/box" ls)" | ||
| 16 | [[ "$out" == "No hermes-box-lite boxes yet. Create one: ./box new <name>" ]] | ||
| 17 | |||
| 18 | # --- box ls (populated registry: one running, one missing from smolvm) - | ||
| 19 | ls_repo="$tmp/ls-repo" | ||
| 20 | make_repo "$ls_repo" | ||
| 21 | mkdir -p "$tmp/ls-state/present-box" | ||
| 22 | printf 'present-box\t4790\t8650\t8650\nghost-box\t\t\t\n' >"$ls_repo/.boxes" | ||
| 23 | out="$(PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/ls.calls" \ | ||
| 24 | SMOLVM_STATE_DIR="$tmp/ls-state" "$ls_repo/box" ls)" | ||
| 25 | grep -q '^BOX *STATE' <<<"$out" | ||
| 26 | grep -q '^present-box *running *http://localhost:4790 *http://localhost:8650' <<<"$out" | ||
| 27 | grep -q '^ghost-box *missing *— *—' <<<"$out" | ||
| 28 | |||
| 29 | # --- box ls --all (needs the smolvm fixture's bare `machine ls`) ------- | ||
| 30 | mkdir -p "$tmp/ls-state/other-vm" | ||
| 31 | out="$(PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/ls-all.calls" \ | ||
| 32 | SMOLVM_STATE_DIR="$tmp/ls-state" "$ls_repo/box" ls --all)" | ||
| 33 | grep -q 'present-box' <<<"$out" | ||
| 34 | grep -q 'other-vm' <<<"$out" | ||
| 35 | |||
| 36 | # --- box rm <name> --force ---------------------------------------------- | ||
| 37 | rm_repo="$tmp/rm-repo" | ||
| 38 | make_repo "$rm_repo" | ||
| 39 | mkdir -p "$tmp/rm-state/force-box" | ||
| 40 | printf 'force-box\t4790\t\t\n' >"$rm_repo/.boxes" | ||
| 41 | rm_sentinel="$tmp/rm-force.calls" | ||
| 42 | out="$(PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$rm_sentinel" \ | ||
| 43 | SMOLVM_STATE_DIR="$tmp/rm-state" "$rm_repo/box" rm force-box --force)" | ||
| 44 | grep -q "deleted 'force-box'" <<<"$out" | ||
| 45 | grep -q -- '--name force-box -f' "$rm_sentinel" | ||
| 46 | [[ ! -s "$rm_repo/.boxes" ]] | ||
| 47 | [[ ! -d "$tmp/rm-state/force-box" ]] | ||
| 48 | |||
| 49 | # --- box rm <name> without --force and without a controlling terminal --- | ||
| 50 | # `[[ -r /dev/tty ]]` on Linux is a permission-bits check, not proof a | ||
| 51 | # controlling terminal exists, so a plain stdin redirect doesn't reach the | ||
| 52 | # "refusing non-interactive deletion" message here (it does on a real | ||
| 53 | # interactive shell). setsid genuinely detaches the terminal, so this | ||
| 54 | # asserts the safety property that actually matters regardless of which | ||
| 55 | # of the two failure modes fires: no confirmation reached, no deletion. | ||
| 56 | # setsid/timeout are util-linux, not on stock macOS — skip rather than fail | ||
| 57 | # the whole suite for a host the project otherwise supports. | ||
| 58 | if ! command -v setsid >/dev/null 2>&1 || ! command -v timeout >/dev/null 2>&1; then | ||
| 59 | echo "setsid/timeout unavailable; skipping the no-controlling-terminal box rm regression" >&2 | ||
| 60 | else | ||
| 61 | rm_noforce_repo="$tmp/rm-noforce-repo" | ||
| 62 | make_repo "$rm_noforce_repo" | ||
| 63 | mkdir -p "$tmp/rm-noforce-state/no-force-box" | ||
| 64 | printf 'no-force-box\t\t\t\n' >"$rm_noforce_repo/.boxes" | ||
| 65 | noforce_sentinel="$tmp/rm-noforce.calls" | ||
| 66 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$noforce_sentinel" \ | ||
| 67 | SMOLVM_STATE_DIR="$tmp/rm-noforce-state" \ | ||
| 68 | timeout 10 setsid "$rm_noforce_repo/box" rm no-force-box </dev/null >/dev/null 2>&1; then | ||
| 69 | echo "box rm without --force unexpectedly succeeded with no controlling terminal" >&2 | ||
| 70 | exit 1 | ||
| 71 | fi | ||
| 72 | [[ ! -e "$noforce_sentinel" ]] || { echo "smolvm ran before rm confirmation" >&2; exit 1; } | ||
| 73 | grep -q '^no-force-box' "$rm_noforce_repo/.boxes" | ||
| 74 | fi | ||
| 75 | |||
| 76 | # --- box rm <name>: typed-name confirmation, both mismatch and match ---- | ||
| 77 | # Exercises the real /dev/tty prompt path via a pty, since `--force` and the | ||
| 78 | # no-tty refusal above don't reach this code at all. | ||
| 79 | rm_tty_repo="$tmp/rm-tty-repo" | ||
| 80 | make_repo "$rm_tty_repo" | ||
| 81 | mkdir -p "$tmp/rm-tty-state/tty-box" | ||
| 82 | printf 'tty-box\t\t\t\n' >"$rm_tty_repo/.boxes" | ||
| 83 | tty_sentinel="$tmp/rm-tty.calls" | ||
| 84 | PROJECT_ROOT="$PROJECT_ROOT" RM_REPO="$rm_tty_repo" RM_STATE="$tmp/rm-tty-state" \ | ||
| 85 | RM_SENTINEL="$tty_sentinel" python3 <<'PY' | ||
| 86 | import os | ||
| 87 | import pty | ||
| 88 | import sys | ||
| 89 | import time | ||
| 90 | |||
| 91 | project_root = os.environ["PROJECT_ROOT"] | ||
| 92 | repo = os.environ["RM_REPO"] | ||
| 93 | state = os.environ["RM_STATE"] | ||
| 94 | sentinel = os.environ["RM_SENTINEL"] | ||
| 95 | |||
| 96 | |||
| 97 | def run(answer): | ||
| 98 | pid, master_fd = pty.fork() | ||
| 99 | if pid == 0: | ||
| 100 | os.environ["PATH"] = project_root + "/tests/fixtures:" + os.environ["PATH"] | ||
| 101 | os.environ["SMOLVM_CALLED"] = sentinel | ||
| 102 | os.environ["SMOLVM_STATE_DIR"] = state | ||
| 103 | os.execv(repo + "/box", [repo + "/box", "rm", "tty-box"]) | ||
| 104 | buf = b"" | ||
| 105 | deadline = time.time() + 10 | ||
| 106 | while b"Type the box name" not in buf and time.time() < deadline: | ||
| 107 | try: | ||
| 108 | chunk = os.read(master_fd, 4096) | ||
| 109 | except OSError: | ||
| 110 | break | ||
| 111 | if not chunk: | ||
| 112 | break | ||
| 113 | buf += chunk | ||
| 114 | os.write(master_fd, (answer + "\n").encode()) | ||
| 115 | try: | ||
| 116 | while True: | ||
| 117 | chunk = os.read(master_fd, 4096) | ||
| 118 | if not chunk: | ||
| 119 | break | ||
| 120 | buf += chunk | ||
| 121 | except OSError: | ||
| 122 | pass | ||
| 123 | _, status = os.waitpid(pid, 0) | ||
| 124 | code = os.WEXITSTATUS(status) if os.WIFEXITED(status) else -1 | ||
| 125 | return code, buf | ||
| 126 | |||
| 127 | code, output = run("wrong-name") | ||
| 128 | if code != 1 or b"deletion cancelled" not in output: | ||
| 129 | sys.exit(f"mismatched confirmation did not refuse deletion: code={code} output={output!r}") | ||
| 130 | if os.path.exists(sentinel): | ||
| 131 | sys.exit("smolvm ran despite a confirmation mismatch") | ||
| 132 | |||
| 133 | code, output = run("tty-box") | ||
| 134 | if code != 0 or b"deleted 'tty-box'" not in output: | ||
| 135 | sys.exit(f"matching confirmation did not delete the box: code={code} output={output!r}") | ||
| 136 | PY | ||
| 137 | [[ ! -s "$rm_tty_repo/.boxes" ]] | ||
| 138 | [[ ! -d "$tmp/rm-tty-state/tty-box" ]] | ||
| 139 | |||
| 140 | # --- box stop / box start: happy path delegates to smolvm --------------- | ||
| 141 | stop_repo="$tmp/stop-repo" | ||
| 142 | make_repo "$stop_repo" | ||
| 143 | mkdir -p "$tmp/stop-state/stop-box" | ||
| 144 | stop_sentinel="$tmp/stop.calls" | ||
| 145 | PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$stop_sentinel" \ | ||
| 146 | SMOLVM_STATE_DIR="$tmp/stop-state" "$stop_repo/box" stop stop-box >/dev/null | ||
| 147 | grep -q -- 'machine stop --name stop-box' "$stop_sentinel" | ||
| 148 | start_sentinel="$tmp/start.calls" | ||
| 149 | PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$start_sentinel" \ | ||
| 150 | SMOLVM_STATE_DIR="$tmp/stop-state" "$stop_repo/box" start stop-box >/dev/null | ||
| 151 | grep -q -- 'machine start --name stop-box' "$start_sentinel" | ||
| 152 | |||
| 153 | # --- box stop: a held per-box lock blocks a concurrent operation -------- | ||
| 154 | lock_repo="$tmp/lock-repo" | ||
| 155 | make_repo "$lock_repo" | ||
| 156 | mkdir -p "$tmp/lock-state/locked-box" "$lock_repo/.box-locks/locked-box.lock" | ||
| 157 | printf '%s\n' "$$" >"$lock_repo/.box-locks/locked-box.lock/pid" | ||
| 158 | locked_sentinel="$tmp/locked.calls" | ||
| 159 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$locked_sentinel" \ | ||
| 160 | SMOLVM_STATE_DIR="$tmp/lock-state" "$lock_repo/box" stop locked-box >/dev/null 2>"$tmp/locked.err"; then | ||
| 161 | echo "box stop unexpectedly succeeded against a held lock" >&2 | ||
| 162 | exit 1 | ||
| 163 | fi | ||
| 164 | grep -q "already holds the 'locked-box' lock" "$tmp/locked.err" | ||
| 165 | [[ ! -e "$locked_sentinel" ]] || { echo "smolvm ran despite a held lock" >&2; exit 1; } | ||
| 166 | |||
| 167 | # --- box enter: execs the expected smolvm command line ------------------- | ||
| 168 | enter_repo="$tmp/enter-repo" | ||
| 169 | make_repo "$enter_repo" | ||
| 170 | mkdir -p "$tmp/enter-state/enter-box" | ||
| 171 | enter_sentinel="$tmp/enter.calls" | ||
| 172 | PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$enter_sentinel" \ | ||
| 173 | SMOLVM_BEHAVIOR=success \ | ||
| 174 | SMOLVM_STATE_DIR="$tmp/enter-state" "$enter_repo/box" enter enter-box >/dev/null | ||
| 175 | grep -q -- 'machine start --name enter-box' "$enter_sentinel" | ||
| 176 | grep -q -- 'machine exec -it --name enter-box -- login -f agent' "$enter_sentinel" | ||
| 177 | |||
| 178 | # --- _assign_port: the exhaustion path dies with the documented message - | ||
| 179 | port_repo="$tmp/port-repo" | ||
| 180 | make_repo "$port_repo" | ||
| 181 | printf 'port-hog\t39999\t\t\n' >"$port_repo/.boxes" | ||
| 182 | if err="$( | ||
| 183 | cd "$port_repo" | ||
| 184 | # shellcheck disable=SC1091 | ||
| 185 | source ./box | ||
| 186 | _assign_port 39999 39999 testlabel 2>&1 | ||
| 187 | )"; then | ||
| 188 | echo "_assign_port unexpectedly succeeded against an exhausted range" >&2 | ||
| 189 | exit 1 | ||
| 190 | else | ||
| 191 | grep -q 'no free host port in 39999-39999' <<<"$err" | ||
| 192 | fi | ||
| 193 | |||
| 194 | echo "cli-surface checks passed" | ||
| @@ -1,105 +0,0 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | state="${SMOLVM_STATE_DIR:?SMOLVM_STATE_DIR must be set}" | ||
| 5 | sentinel="${SMOLVM_CALLED:?SMOLVM_CALLED must point to a sentinel}" | ||
| 6 | behavior="${SMOLVM_BEHAVIOR:-fail-exec}" | ||
| 7 | mkdir -p "$state" | ||
| 8 | printf '%q ' "$@" >>"$sentinel" | ||
| 9 | printf '\n' >>"$sentinel" | ||
| 10 | |||
| 11 | name_from_args() { | ||
| 12 | local previous="" value | ||
| 13 | for value in "$@"; do | ||
| 14 | if [[ "$previous" == --name ]]; then printf '%s' "$value"; return; fi | ||
| 15 | previous="$value" | ||
| 16 | done | ||
| 17 | return 1 | ||
| 18 | } | ||
| 19 | |||
| 20 | [[ "${1:-}" == machine ]] || exit 2 | ||
| 21 | case "${2:-}" in | ||
| 22 | ls) | ||
| 23 | printf 'NAME\tSTATUS\n' | ||
| 24 | for entry in "$state"/*/; do | ||
| 25 | [[ -d "$entry" ]] || continue | ||
| 26 | printf '%s\trunning\n' "$(basename "$entry")" | ||
| 27 | done | ||
| 28 | ;; | ||
| 29 | status) | ||
| 30 | name="$(name_from_args "$@")" | ||
| 31 | [[ "$behavior" != delete-status-fail ]] || exit 42 | ||
| 32 | [[ -d "$state/$name" ]] || exit 1 | ||
| 33 | if [[ "$behavior" == inactive || "$behavior" == inactive-import-post-fail ]]; then | ||
| 34 | printf "Machine '%s': stopped\n" "$name" | ||
| 35 | else | ||
| 36 | printf "Machine '%s': running\n" "$name" | ||
| 37 | fi | ||
| 38 | ;; | ||
| 39 | create) | ||
| 40 | name="$(name_from_args "$@")" | ||
| 41 | [[ ! -d "$state/$name" ]] || exit 1 | ||
| 42 | mkdir -p "$state/$name" | ||
| 43 | [[ "$behavior" != create-race ]] || exit 1 | ||
| 44 | if [[ "$behavior" == signal-create-success ]]; then kill -TERM "$PPID"; fi | ||
| 45 | ;; | ||
| 46 | start | stop) | ||
| 47 | name="$(name_from_args "$@")" | ||
| 48 | # Project syntax is `smolvm machine start`; the action is argument two. | ||
| 49 | if [[ "${2:-}" == start && "$behavior" == start-delete-fail ]]; then exit 42; fi | ||
| 50 | [[ -d "$state/$name" ]] | ||
| 51 | ;; | ||
| 52 | delete) | ||
| 53 | name="$(name_from_args "$@")" | ||
| 54 | [[ "$behavior" != delete-fail && "$behavior" != delete-status-fail && "$behavior" != start-delete-fail ]] || exit 42 | ||
| 55 | rm -rf "${state:?}/${name:?}" | ||
| 56 | ;; | ||
| 57 | exec) | ||
| 58 | name="$(name_from_args "$@")" | ||
| 59 | if [[ " $* " == *" -i "* ]]; then cat >/dev/null; fi | ||
| 60 | if [[ "$behavior" == fail-exec ]]; then exit 42; fi | ||
| 61 | if [[ "$behavior" == block-provision && "$*" == *"provision/provision.sh 'full'"* ]]; then | ||
| 62 | kill -TERM "$PPID" | ||
| 63 | sleep 1 | ||
| 64 | exit 143 | ||
| 65 | fi | ||
| 66 | if [[ "$behavior" == registry-contention && "$*" == *"provision/provision.sh 'full'"* ]]; then | ||
| 67 | mkdir -p "${SMOLVM_REGISTRY_LOCK_DIR:?missing registry lock dir}" | ||
| 68 | printf '%s\n' "${SMOLVM_REGISTRY_LOCK_OWNER_PID:?missing lock owner pid}" >"$SMOLVM_REGISTRY_LOCK_DIR/pid" | ||
| 69 | exit 42 | ||
| 70 | fi | ||
| 71 | if [[ "$behavior" == stale-registry-lock && "$*" == *"provision/provision.sh 'full'"* ]]; then | ||
| 72 | mkdir -p "${SMOLVM_REGISTRY_LOCK_DIR:?missing registry lock dir}" | ||
| 73 | printf '99999999\n' >"$SMOLVM_REGISTRY_LOCK_DIR/pid" | ||
| 74 | exit 42 | ||
| 75 | fi | ||
| 76 | if [[ ( "$behavior" == delete-fail || "$behavior" == delete-status-fail ) && "$*" == *"provision/provision.sh 'full'"* ]]; then exit 42; fi | ||
| 77 | if [[ "$behavior" == restore-tar-fail && "$*" == *"archive=\$1"* && "$*" == *"tar xzf"* ]]; then | ||
| 78 | exit 42 | ||
| 79 | fi | ||
| 80 | if [[ "$behavior" == signal-import && "$*" == *"hermes-state import-zip"* ]]; then | ||
| 81 | kill -TERM "$PPID" | ||
| 82 | sleep 1 | ||
| 83 | exit 143 | ||
| 84 | fi | ||
| 85 | if [[ ( "$behavior" == import-post-fail || "$behavior" == inactive-import-post-fail ) && "$*" == *" hb verify-state"* ]]; then exit 42; fi | ||
| 86 | if [[ "$*" == *" hb is-paused"* ]]; then [[ -e "$state/$name/paused" ]]; exit; fi | ||
| 87 | if [[ "$*" == *" hb pause"* ]]; then | ||
| 88 | touch "$state/$name/paused" | ||
| 89 | [[ "$behavior" != pause-fail ]] || exit 42 | ||
| 90 | fi | ||
| 91 | if [[ "$*" == *" hb gateway-disable"* ]]; then touch "$state/$name/gateway-disabled"; fi | ||
| 92 | if [[ "$*" == *" hb resume"* ]]; then | ||
| 93 | rm -f "$state/$name/paused" | ||
| 94 | [[ -e "$state/$name/gateway-disabled" ]] || touch "$state/$name/gateway-restarted" | ||
| 95 | fi | ||
| 96 | if [[ "$*" == *" hb executor-token"* ]]; then printf 'fixture-token\n'; fi | ||
| 97 | ;; | ||
| 98 | cp) | ||
| 99 | if [[ "$behavior" == save-cp-fail ]]; then exit 42; fi | ||
| 100 | if [[ -n "${SMOLVM_ARCHIVE_SOURCE:-}" && "${3:-}" == *:* ]]; then | ||
| 101 | cp "$SMOLVM_ARCHIVE_SOURCE" "${4:?missing copy destination}" | ||
| 102 | fi | ||
| 103 | ;; | ||
| 104 | *) exit 2 ;; | ||
| 105 | esac | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | #!/usr/bin/env bash | 1 | #!/usr/bin/env bash |
| 2 | # Shared test harness: a fresh $tmp per file, cleaned up on exit or signal | 2 | # 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 | ||
| 7 | PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | 7 | PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| @@ -57,6 +57,6 @@ wait_for_pattern() { · 49 unmodified lines | |||
| 57 | make_repo() { | 57 | make_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 | } |
| @@ -1,258 +0,0 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | # shellcheck source=tests/lib.sh | ||
| 5 | source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" | ||
| 6 | |||
| 7 | mkdir -p "$tmp/valid/home/agent/workspace" "$tmp/invalid/not-home" "$tmp/gnupg" "$tmp/host-tmp" | ||
| 8 | chmod 0700 "$tmp/gnupg" | ||
| 9 | printf 'portable state\n' >"$tmp/valid/home/agent/workspace/probe.txt" | ||
| 10 | ln -s probe.txt "$tmp/valid/home/agent/workspace/probe-link" | ||
| 11 | ln "$tmp/valid/home/agent/workspace/probe.txt" "$tmp/valid/home/agent/workspace/probe-hard" | ||
| 12 | printf 'invalid state\n' >"$tmp/invalid/not-home/probe.txt" | ||
| 13 | tar czf "$tmp/valid.tgz" -C "$tmp/valid" . | ||
| 14 | tar czf "$tmp/invalid.tgz" -C "$tmp/invalid" . | ||
| 15 | python3 - "$tmp/absolute-symlink.tgz" "$tmp/traversal-symlink.tgz" \ | ||
| 16 | "$tmp/invalid-hardlink.tgz" "$tmp/special-file.tgz" "$tmp/home-link.tgz" \ | ||
| 17 | "$tmp/cycle-link.tgz" "$tmp/pivot-link.tgz" "$tmp/root-symlink.tgz" \ | ||
| 18 | "$tmp/root-hardlink.tgz" "$tmp/linked-parent-alias.tgz" \ | ||
| 19 | "$tmp/linked-parent-regular.tgz" "$tmp/control-char-name.tgz" \ | ||
| 20 | "$tmp/control-char-link.tgz" <<'PY' | ||
| 21 | import io | ||
| 22 | import sys | ||
| 23 | import tarfile | ||
| 24 | |||
| 25 | def directory(name): | ||
| 26 | info = tarfile.TarInfo(name) | ||
| 27 | info.type = tarfile.DIRTYPE | ||
| 28 | info.mode = 0o700 | ||
| 29 | return info | ||
| 30 | |||
| 31 | with tarfile.open(sys.argv[1], "w:gz") as archive: | ||
| 32 | archive.addfile(directory("home")) | ||
| 33 | archive.addfile(directory("home/agent")) | ||
| 34 | link = tarfile.TarInfo("home/agent/workspace") | ||
| 35 | link.type = tarfile.SYMTYPE | ||
| 36 | link.linkname = "/tmp/escape" | ||
| 37 | archive.addfile(link) | ||
| 38 | |||
| 39 | with tarfile.open(sys.argv[2], "w:gz") as archive: | ||
| 40 | archive.addfile(directory("home")) | ||
| 41 | archive.addfile(directory("home/agent")) | ||
| 42 | link = tarfile.TarInfo("home/escape-link") | ||
| 43 | link.type = tarfile.SYMTYPE | ||
| 44 | link.linkname = "../../" | ||
| 45 | archive.addfile(link) | ||
| 46 | |||
| 47 | with tarfile.open(sys.argv[3], "w:gz") as archive: | ||
| 48 | archive.addfile(directory("home")) | ||
| 49 | archive.addfile(directory("home/agent")) | ||
| 50 | link = tarfile.TarInfo("home/agent/workspace") | ||
| 51 | link.type = tarfile.LNKTYPE | ||
| 52 | link.linkname = "../../escape" | ||
| 53 | archive.addfile(link) | ||
| 54 | |||
| 55 | with tarfile.open(sys.argv[4], "w:gz") as archive: | ||
| 56 | archive.addfile(directory("home")) | ||
| 57 | archive.addfile(directory("home/agent")) | ||
| 58 | fifo = tarfile.TarInfo("home/agent/runtime.fifo") | ||
| 59 | fifo.type = tarfile.FIFOTYPE | ||
| 60 | archive.addfile(fifo) | ||
| 61 | |||
| 62 | with tarfile.open(sys.argv[5], "w:gz") as archive: | ||
| 63 | archive.addfile(directory("home")) | ||
| 64 | link = tarfile.TarInfo("home/agent") | ||
| 65 | link.type = tarfile.SYMTYPE | ||
| 66 | link.linkname = "/" | ||
| 67 | archive.addfile(link) | ||
| 68 | |||
| 69 | with tarfile.open(sys.argv[6], "w:gz") as archive: | ||
| 70 | archive.addfile(directory("home")) | ||
| 71 | archive.addfile(directory("home/agent")) | ||
| 72 | first = tarfile.TarInfo("home/agent/first") | ||
| 73 | first.type = tarfile.SYMTYPE | ||
| 74 | first.linkname = "second" | ||
| 75 | archive.addfile(first) | ||
| 76 | second = tarfile.TarInfo("home/agent/second") | ||
| 77 | second.type = tarfile.SYMTYPE | ||
| 78 | second.linkname = "first" | ||
| 79 | archive.addfile(second) | ||
| 80 | |||
| 81 | with tarfile.open(sys.argv[7], "w:gz") as archive: | ||
| 82 | archive.addfile(directory(".")) | ||
| 83 | archive.addfile(directory("home")) | ||
| 84 | archive.addfile(directory("home/agent")) | ||
| 85 | victim_data = b"victim\n" | ||
| 86 | victim = tarfile.TarInfo("home/agent/victim") | ||
| 87 | victim.size = len(victim_data) | ||
| 88 | archive.addfile(victim, io.BytesIO(victim_data)) | ||
| 89 | up = tarfile.TarInfo("home/agent/up") | ||
| 90 | up.type = tarfile.SYMTYPE | ||
| 91 | up.linkname = "../.." | ||
| 92 | archive.addfile(up) | ||
| 93 | pivot = tarfile.TarInfo("home/agent/x") | ||
| 94 | pivot.type = tarfile.SYMTYPE | ||
| 95 | pivot.linkname = "up/../victim" | ||
| 96 | archive.addfile(pivot) | ||
| 97 | |||
| 98 | with tarfile.open(sys.argv[8], "w:gz") as archive: | ||
| 99 | root = tarfile.TarInfo("./") | ||
| 100 | root.type = tarfile.SYMTYPE | ||
| 101 | root.linkname = "home" | ||
| 102 | archive.addfile(root) | ||
| 103 | archive.addfile(directory("home")) | ||
| 104 | archive.addfile(directory("home/agent")) | ||
| 105 | |||
| 106 | with tarfile.open(sys.argv[9], "w:gz") as archive: | ||
| 107 | root = tarfile.TarInfo(".") | ||
| 108 | root.type = tarfile.LNKTYPE | ||
| 109 | root.linkname = "home/agent" | ||
| 110 | archive.addfile(root) | ||
| 111 | archive.addfile(directory("home")) | ||
| 112 | archive.addfile(directory("home/agent")) | ||
| 113 | |||
| 114 | with tarfile.open(sys.argv[10], "w:gz") as archive: | ||
| 115 | archive.addfile(directory(".")) | ||
| 116 | archive.addfile(directory("home")) | ||
| 117 | archive.addfile(directory("home/agent")) | ||
| 118 | up = tarfile.TarInfo("home/agent/up") | ||
| 119 | up.type = tarfile.SYMTYPE | ||
| 120 | up.linkname = "../.." | ||
| 121 | archive.addfile(up) | ||
| 122 | alias = tarfile.TarInfo("home/agent/up/home") | ||
| 123 | alias.type = tarfile.SYMTYPE | ||
| 124 | alias.linkname = "/tmp/linked-parent-escape" | ||
| 125 | archive.addfile(alias) | ||
| 126 | |||
| 127 | with tarfile.open(sys.argv[11], "w:gz") as archive: | ||
| 128 | archive.addfile(directory(".")) | ||
| 129 | archive.addfile(directory("home")) | ||
| 130 | archive.addfile(directory("home/agent")) | ||
| 131 | up = tarfile.TarInfo("home/agent/up") | ||
| 132 | up.type = tarfile.SYMTYPE | ||
| 133 | up.linkname = "../.." | ||
| 134 | archive.addfile(up) | ||
| 135 | payload = b"must not extract through link\n" | ||
| 136 | child = tarfile.TarInfo("home/agent/up/owned") | ||
| 137 | child.size = len(payload) | ||
| 138 | archive.addfile(child, io.BytesIO(payload)) | ||
| 139 | |||
| 140 | with tarfile.open(sys.argv[12], "w:gz") as archive: | ||
| 141 | archive.addfile(directory("home")) | ||
| 142 | archive.addfile(directory("home/agent")) | ||
| 143 | bad = tarfile.TarInfo("home/agent/bad\x01name") | ||
| 144 | payload = b"control character in member name\n" | ||
| 145 | bad.size = len(payload) | ||
| 146 | archive.addfile(bad, io.BytesIO(payload)) | ||
| 147 | |||
| 148 | with tarfile.open(sys.argv[13], "w:gz") as archive: | ||
| 149 | archive.addfile(directory("home")) | ||
| 150 | archive.addfile(directory("home/agent")) | ||
| 151 | link = tarfile.TarInfo("home/agent/bad-link") | ||
| 152 | link.type = tarfile.SYMTYPE | ||
| 153 | link.linkname = "bad\x01target" | ||
| 154 | archive.addfile(link) | ||
| 155 | PY | ||
| 156 | |||
| 157 | ( | ||
| 158 | # shellcheck disable=SC1090 | ||
| 159 | source "$PROJECT_ROOT/box" | ||
| 160 | _validate_name valid-box_1.2 | ||
| 161 | _validate_archive "$tmp/valid.tgz" | ||
| 162 | if _validate_archive "$tmp/invalid.tgz"; then | ||
| 163 | echo "archive without durable home passed validation" >&2 | ||
| 164 | exit 1 | ||
| 165 | fi | ||
| 166 | for unsafe in "$tmp/absolute-symlink.tgz" "$tmp/traversal-symlink.tgz" \ | ||
| 167 | "$tmp/invalid-hardlink.tgz" "$tmp/special-file.tgz" "$tmp/home-link.tgz" \ | ||
| 168 | "$tmp/cycle-link.tgz" "$tmp/pivot-link.tgz" "$tmp/root-symlink.tgz" \ | ||
| 169 | "$tmp/root-hardlink.tgz" "$tmp/linked-parent-alias.tgz" \ | ||
| 170 | "$tmp/linked-parent-regular.tgz" "$tmp/control-char-name.tgz" \ | ||
| 171 | "$tmp/control-char-link.tgz"; do | ||
| 172 | if _validate_archive "$unsafe"; then | ||
| 173 | echo "linked archive passed validation: $unsafe" >&2 | ||
| 174 | exit 1 | ||
| 175 | fi | ||
| 176 | done | ||
| 177 | ) | ||
| 178 | |||
| 179 | if ( | ||
| 180 | # shellcheck disable=SC1090 | ||
| 181 | source "$PROJECT_ROOT/box" | ||
| 182 | _validate_name '../unsafe' | ||
| 183 | ); then | ||
| 184 | echo "unsafe box name passed validation" >&2 | ||
| 185 | exit 1 | ||
| 186 | fi | ||
| 187 | |||
| 188 | export GNUPGHOME="$tmp/gnupg" | ||
| 189 | export BOX_PASSPHRASE='lifecycle-smoke-passphrase' | ||
| 190 | gpg --batch --yes --pinentry-mode loopback --passphrase "$BOX_PASSPHRASE" \ | ||
| 191 | --symmetric --cipher-algo AES256 -o "$tmp/valid.tar.gz.gpg" "$tmp/valid.tgz" | ||
| 192 | gpg --batch --yes --pinentry-mode loopback --passphrase "$BOX_PASSPHRASE" \ | ||
| 193 | --symmetric --cipher-algo AES256 -o "$tmp/invalid.tar.gz.gpg" "$tmp/invalid.tgz" | ||
| 194 | for unsafe in absolute-symlink traversal-symlink invalid-hardlink special-file home-link cycle-link \ | ||
| 195 | pivot-link root-symlink root-hardlink linked-parent-alias linked-parent-regular \ | ||
| 196 | control-char-name control-char-link; do | ||
| 197 | gpg --batch --yes --pinentry-mode loopback --passphrase "$BOX_PASSPHRASE" \ | ||
| 198 | --symmetric --cipher-algo AES256 -o "$tmp/${unsafe}.tar.gz.gpg" "$tmp/${unsafe}.tgz" | ||
| 199 | done | ||
| 200 | |||
| 201 | TMPDIR="$tmp/host-tmp" "$PROJECT_ROOT/box" extract "$tmp/valid.tar.gz.gpg" "$tmp/extracted" >/dev/null | ||
| 202 | cmp "$tmp/valid/home/agent/workspace/probe.txt" "$tmp/extracted/home/agent/workspace/probe.txt" | ||
| 203 | [[ -L "$tmp/extracted/home/agent/workspace/probe-link" ]] | ||
| 204 | [[ "$(readlink "$tmp/extracted/home/agent/workspace/probe-link")" == probe.txt ]] | ||
| 205 | cmp "$tmp/extracted/home/agent/workspace/probe-link" "$tmp/extracted/home/agent/workspace/probe.txt" | ||
| 206 | python3 - "$tmp/extracted/home/agent/workspace/probe.txt" \ | ||
| 207 | "$tmp/extracted/home/agent/workspace/probe-hard" <<'PY' | ||
| 208 | import os | ||
| 209 | import sys | ||
| 210 | assert os.stat(sys.argv[1]).st_ino == os.stat(sys.argv[2]).st_ino | ||
| 211 | PY | ||
| 212 | [[ -z "$(find "$tmp/host-tmp" -mindepth 1 -maxdepth 1 -print -quit)" ]] | ||
| 213 | |||
| 214 | for unsafe in absolute-symlink traversal-symlink invalid-hardlink special-file home-link cycle-link \ | ||
| 215 | pivot-link root-symlink root-hardlink linked-parent-alias linked-parent-regular \ | ||
| 216 | control-char-name control-char-link; do | ||
| 217 | unsafe_sentinel="$tmp/${unsafe}-smolvm-called" | ||
| 218 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$unsafe_sentinel" \ | ||
| 219 | SMOLVM_STATE_DIR="$tmp/smolvm-state" TMPDIR="$tmp/host-tmp" \ | ||
| 220 | "$PROJECT_ROOT/box" load "$tmp/${unsafe}.tar.gz.gpg" "${unsafe}-destination" >/dev/null 2>&1; then | ||
| 221 | echo "$unsafe archive unexpectedly loaded" >&2 | ||
| 222 | exit 1 | ||
| 223 | fi | ||
| 224 | [[ ! -e "$unsafe_sentinel" ]] || { echo "VM command ran before $unsafe validation" >&2; exit 1; } | ||
| 225 | done | ||
| 226 | |||
| 227 | sentinel="$tmp/smolvm-called" | ||
| 228 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$sentinel" \ | ||
| 229 | SMOLVM_STATE_DIR="$tmp/smolvm-state" \ | ||
| 230 | TMPDIR="$tmp/host-tmp" "$PROJECT_ROOT/box" load "$tmp/invalid.tar.gz.gpg" invalid-destination >/dev/null 2>&1; then | ||
| 231 | echo "invalid archive unexpectedly loaded" >&2 | ||
| 232 | exit 1 | ||
| 233 | fi | ||
| 234 | [[ ! -e "$sentinel" ]] || { echo "VM command ran before archive validation" >&2; exit 1; } | ||
| 235 | [[ -z "$(find "$tmp/host-tmp" -mindepth 1 -maxdepth 1 -print -quit)" ]] | ||
| 236 | |||
| 237 | make_repo "$tmp/repo" | ||
| 238 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$sentinel" \ | ||
| 239 | SMOLVM_STATE_DIR="$tmp/smolvm-state" "$tmp/repo/box" new rollback-probe >/dev/null 2>&1; then | ||
| 240 | echo "mock provisioning failure unexpectedly succeeded" >&2 | ||
| 241 | exit 1 | ||
| 242 | fi | ||
| 243 | [[ ! -d "$tmp/smolvm-state/rollback-probe" ]] || { echo "failed box was not deleted" >&2; exit 1; } | ||
| 244 | [[ ! -s "$tmp/repo/.boxes" ]] || { echo "failed box remained registered" >&2; exit 1; } | ||
| 245 | [[ -z "$(find "$tmp/repo/.box-locks" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]] || { | ||
| 246 | echo "operation lock survived process exit" >&2 | ||
| 247 | exit 1 | ||
| 248 | } | ||
| 249 | |||
| 250 | if BOX_PASSPHRASE=wrong TMPDIR="$tmp/host-tmp" \ | ||
| 251 | "$PROJECT_ROOT/box" extract "$tmp/valid.tar.gz.gpg" "$tmp/wrong-pass" >/dev/null 2>&1; then | ||
| 252 | echo "wrong passphrase unexpectedly succeeded" >&2 | ||
| 253 | exit 1 | ||
| 254 | fi | ||
| 255 | [[ ! -e "$tmp/wrong-pass" ]] | ||
| 256 | [[ -z "$(find "$tmp/host-tmp" -mindepth 1 -maxdepth 1 -print -quit)" ]] | ||
| 257 | |||
| 258 | echo "lifecycle smoke checks passed" | ||
| @@ -1,90 +0,0 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # shellcheck disable=SC2016,SC2030,SC2031,SC2329 | ||
| 3 | set -euo pipefail | ||
| 4 | |||
| 5 | # shellcheck source=tests/lib.sh | ||
| 6 | source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" | ||
| 7 | |||
| 8 | # Root-run installer state is recursively returned to the agent and made | ||
| 9 | # writable, including nested runtime directories created during full repair. | ||
| 10 | hermes_fixture="$tmp/installer-hermes-home" | ||
| 11 | mkdir -p "$hermes_fixture/sessions/deep" "$hermes_fixture/cron" "$hermes_fixture/logs" | ||
| 12 | printf 'session\n' >"$hermes_fixture/sessions/deep/history.json" | ||
| 13 | printf 'job\n' >"$hermes_fixture/cron/jobs.json" | ||
| 14 | chmod 0500 "$hermes_fixture/sessions/deep" | ||
| 15 | chmod 0400 "$hermes_fixture/sessions/deep/history.json" "$hermes_fixture/cron/jobs.json" | ||
| 16 | ( | ||
| 17 | # shellcheck disable=SC1090 | ||
| 18 | source "$PROJECT_ROOT/provision/provision.sh" | ||
| 19 | HERMES_HOME="$hermes_fixture" | ||
| 20 | chown() { | ||
| 21 | [[ "$1" == -R && "$2" == agent:agent && "$3" == "$hermes_fixture" ]] | ||
| 22 | printf 'recursive agent ownership\n' >"$tmp/hermes-chown.called" | ||
| 23 | } | ||
| 24 | own_hermes_home | ||
| 25 | ) | ||
| 26 | [[ -s "$tmp/hermes-chown.called" ]] | ||
| 27 | [[ -w "$hermes_fixture/sessions/deep/history.json" ]] | ||
| 28 | [[ -w "$hermes_fixture/cron/jobs.json" ]] | ||
| 29 | [[ -w "$hermes_fixture/sessions/deep" ]] | ||
| 30 | |||
| 31 | # Restore staging grants non-owners traversal but not top-level mutation; only | ||
| 32 | # the nested agent home models owner-writable imported state. | ||
| 33 | permission_stage="$tmp/restore-permission-model" | ||
| 34 | mkdir -p "$permission_stage/home/agent" | ||
| 35 | chmod 0711 "$permission_stage" "$permission_stage/home" | ||
| 36 | chmod 0700 "$permission_stage/home/agent" | ||
| 37 | python3 - "$permission_stage" <<'PY' | ||
| 38 | import os | ||
| 39 | import stat | ||
| 40 | import sys | ||
| 41 | from pathlib import Path | ||
| 42 | |||
| 43 | stage = Path(sys.argv[1]) | ||
| 44 | home = stage / "home" | ||
| 45 | agent = home / "agent" | ||
| 46 | for envelope in (stage, home): | ||
| 47 | mode = stat.S_IMODE(envelope.stat().st_mode) | ||
| 48 | assert mode == 0o711 | ||
| 49 | assert mode & 0o001 | ||
| 50 | assert not mode & 0o002 | ||
| 51 | assert stat.S_IMODE(agent.stat().st_mode) == 0o700 | ||
| 52 | assert os.access(agent, os.W_OK) | ||
| 53 | PY | ||
| 54 | |||
| 55 | # Missing legacy resource settings use the documented production defaults. | ||
| 56 | ( | ||
| 57 | # shellcheck disable=SC1090 | ||
| 58 | source "$PROJECT_ROOT/box" | ||
| 59 | unset BOX_CPUS BOX_MEM_MIB BOX_OVERLAY_GIB BOX_STORAGE_GIB | ||
| 60 | _validate_resources | ||
| 61 | [[ "$BOX_CPUS" == 4 && "$BOX_MEM_MIB" == 8192 && "$BOX_OVERLAY_GIB" == 64 && -z "$BOX_STORAGE_GIB" ]] | ||
| 62 | ) | ||
| 63 | |||
| 64 | # Guest bundle verification is independent of the caller's repository and | ||
| 65 | # rejects incremental bundles whose prerequisite objects are unavailable. | ||
| 66 | bundle_repo="$tmp/bundle-source" | ||
| 67 | mkdir -p "$bundle_repo" "$tmp/bundle-nonrepo" "$tmp/bundle-verify-tmp" | ||
| 68 | git -C "$bundle_repo" init -q | ||
| 69 | printf 'base\n' >"$bundle_repo/state.txt" | ||
| 70 | git -C "$bundle_repo" add state.txt | ||
| 71 | git -C "$bundle_repo" -c user.name=Fixture -c user.email=fixture@example.invalid commit -q -m base | ||
| 72 | base_commit="$(git -C "$bundle_repo" rev-parse HEAD)" | ||
| 73 | git -C "$bundle_repo" bundle create "$tmp/complete.bundle" --all | ||
| 74 | printf 'next\n' >>"$bundle_repo/state.txt" | ||
| 75 | git -C "$bundle_repo" add state.txt | ||
| 76 | git -C "$bundle_repo" -c user.name=Fixture -c user.email=fixture@example.invalid commit -q -m next | ||
| 77 | git -C "$bundle_repo" bundle create "$tmp/incremental.bundle" HEAD "^$base_commit" | ||
| 78 | ( | ||
| 79 | cd "$tmp/bundle-nonrepo" | ||
| 80 | # shellcheck disable=SC1090 | ||
| 81 | source "$PROJECT_ROOT/provision/provision.sh" | ||
| 82 | TMPDIR="$tmp/bundle-verify-tmp" verify_self_contained_bundle "$tmp/complete.bundle" | ||
| 83 | if TMPDIR="$tmp/bundle-verify-tmp" verify_self_contained_bundle "$tmp/incremental.bundle"; then | ||
| 84 | echo "incremental bundle passed self-contained verification" >&2 | ||
| 85 | exit 1 | ||
| 86 | fi | ||
| 87 | ) | ||
| 88 | [[ -z "$(find "$tmp/bundle-verify-tmp" -mindepth 1 -maxdepth 1 -print -quit)" ]] | ||
| 89 | |||
| 90 | echo "box-core regression checks passed" | ||
| @@ -1,357 +0,0 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # shellcheck disable=SC2016,SC2030,SC2031,SC2329 | ||
| 3 | set -euo pipefail | ||
| 4 | |||
| 5 | # shellcheck source=tests/lib.sh | ||
| 6 | source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" | ||
| 7 | |||
| 8 | start_mcp_server() { | ||
| 9 | local status="$1" label="$2" mode="${3:-valid}" bind_host="${4:-127.0.0.1}" port_file delete_file | ||
| 10 | port_file="$tmp/$label.port" | ||
| 11 | delete_file="$tmp/$label.delete" | ||
| 12 | python3 "$PROJECT_ROOT/tests/fixtures/mcp-server.py" "$port_file" "$status" "$delete_file" "$mode" "$bind_host" & | ||
| 13 | MCP_PID=$! | ||
| 14 | pids+=("$MCP_PID") | ||
| 15 | wait_for_file "$port_file" | ||
| 16 | MCP_PORT="$(cat "$port_file")" | ||
| 17 | MCP_DELETE_FILE="$delete_file" | ||
| 18 | } | ||
| 19 | |||
| 20 | # Protocol-correct MCP initialize requires exact 200 plus a session header and | ||
| 21 | # closes the created session. Other HTTP statuses are unhealthy. | ||
| 22 | ( | ||
| 23 | # shellcheck disable=SC1090 | ||
| 24 | source "$PROJECT_ROOT/box" | ||
| 25 | REG="$tmp/empty-registry" | ||
| 26 | touch "$REG" | ||
| 27 | |||
| 28 | ( | ||
| 29 | doctor_calls="$tmp/doctor.calls" | ||
| 30 | mock_quiesced=0 | ||
| 31 | mock_gateway_disabled=0 | ||
| 32 | have() { [[ "$1" == awk || "$1" == smolvm ]]; } | ||
| 33 | _acquire_lock() { :; } | ||
| 34 | _machine_exists() { :; } | ||
| 35 | _guest_hb() { | ||
| 36 | printf '%s\n' "$2" >>"$doctor_calls" | ||
| 37 | if [[ "$2" == is-paused ]]; then [[ "$mock_quiesced" == 1 ]]; return; fi | ||
| 38 | if [[ "$2" == gateway-is-disabled ]]; then [[ "$mock_gateway_disabled" == 1 ]]; return; fi | ||
| 39 | [[ "$2" != executor-token ]] || printf 'fixture-token\n' | ||
| 40 | } | ||
| 41 | mcp_attempts=0 | ||
| 42 | sleep() { :; } | ||
| 43 | _mcp_initialize() { | ||
| 44 | printf 'host-mcp\n' >>"$doctor_calls" | ||
| 45 | mcp_attempts=$((mcp_attempts + 1)) | ||
| 46 | [[ "$mcp_attempts" -ge 3 ]] | ||
| 47 | } | ||
| 48 | |||
| 49 | doctor_output="$(cmd_doctor no-host-port)" | ||
| 50 | grep -q 'host Executor MCP: skipped (no exposed host port)' <<<"$doctor_output" | ||
| 51 | [[ "$(cat "$doctor_calls")" == $'is-paused\ndoctor' ]] | ||
| 52 | |||
| 53 | printf 'doctor-host\t4899\n' >"$REG" | ||
| 54 | if (cmd_doctor doctor-host >/dev/null 2>&1); then | ||
| 55 | echo "exposed-port doctor passed without host health dependencies" >&2 | ||
| 56 | exit 1 | ||
| 57 | fi | ||
| 58 | [[ "$(cat "$doctor_calls")" == $'is-paused\ndoctor\nis-paused\ndoctor' ]] | ||
| 59 | |||
| 60 | : >"$doctor_calls" | ||
| 61 | have() { :; } | ||
| 62 | cmd_doctor doctor-host >/dev/null | ||
| 63 | [[ "$(cat "$doctor_calls")" == $'is-paused\ndoctor\nexecutor-token\nhost-mcp\nhost-mcp\nhost-mcp' ]] | ||
| 64 | |||
| 65 | : >"$doctor_calls" | ||
| 66 | mock_quiesced=1 | ||
| 67 | cmd_doctor doctor-host >/dev/null | ||
| 68 | [[ "$(cat "$doctor_calls")" == $'is-paused\ndoctor' ]] | ||
| 69 | mock_quiesced=0 | ||
| 70 | |||
| 71 | : >"$doctor_calls" | ||
| 72 | printf 'api-host\t\t8650\t8650\n' >"$REG" | ||
| 73 | _hermes_api_healthy() { printf 'host-api\n' >>"$doctor_calls"; } | ||
| 74 | api_output="$(cmd_doctor api-host)" | ||
| 75 | grep -q 'host Hermes API: healthy' <<<"$api_output" | ||
| 76 | [[ "$(cat "$doctor_calls")" == $'is-paused\ngateway-is-disabled\ndoctor\nhost-api' ]] | ||
| 77 | |||
| 78 | : >"$doctor_calls" | ||
| 79 | mock_gateway_disabled=1 | ||
| 80 | _hermes_api_healthy() { touch "$tmp/disabled-api-probed"; return 1; } | ||
| 81 | api_output="$(cmd_doctor api-host)" | ||
| 82 | grep -q 'host Hermes API: intentionally disabled by gateway policy' <<<"$api_output" | ||
| 83 | [[ "$(cat "$doctor_calls")" == $'is-paused\ngateway-is-disabled\ndoctor' ]] | ||
| 84 | [[ ! -e "$tmp/disabled-api-probed" ]] | ||
| 85 | mock_gateway_disabled=0 | ||
| 86 | |||
| 87 | : >"$doctor_calls" | ||
| 88 | : >"$REG" | ||
| 89 | WIRE_EXECUTOR_MCP=0 INSTALL_EXECUTOR=0 cmd_doctor disabled-executor >/dev/null | ||
| 90 | [[ "$(cat "$doctor_calls")" == $'is-paused\ndoctor' ]] | ||
| 91 | ) | ||
| 92 | |||
| 93 | BOX_BROWSER=true _open_url 'http://localhost:1/?_token=not-printed' | ||
| 94 | if BOX_BROWSER=definitely-not-a-browser _open_url 'http://localhost:1/?_token=not-printed'; then | ||
| 95 | echo "missing BOX_BROWSER command unexpectedly succeeded" >&2 | ||
| 96 | exit 1 | ||
| 97 | fi | ||
| 98 | uname() { printf 'Linux\n'; } | ||
| 99 | xdg-open() { return 1; } | ||
| 100 | gio() { [[ "$1" == open && "$2" == 'https://fixture.invalid/secret' ]]; } | ||
| 101 | _open_url 'https://fixture.invalid/secret' | ||
| 102 | uname() { printf 'Darwin\n'; } | ||
| 103 | open() { [[ "$1" == 'https://fixture.invalid/secret' ]]; } | ||
| 104 | _open_url 'https://fixture.invalid/secret' | ||
| 105 | |||
| 106 | start_mcp_server 200 healthy | ||
| 107 | _hermes_api_healthy "$MCP_PORT" | ||
| 108 | _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token | ||
| 109 | wait_for_pattern initialized "$MCP_DELETE_FILE" | ||
| 110 | wait_for_pattern deleted "$MCP_DELETE_FILE" | ||
| 111 | _wait_mcp_ready "http://127.0.0.1:$MCP_PORT/mcp" fixture-token | ||
| 112 | if _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" wrong-token; then | ||
| 113 | echo "invalid bearer token passed MCP health" >&2 | ||
| 114 | exit 1 | ||
| 115 | fi | ||
| 116 | malformed_code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ | ||
| 117 | -H 'Authorization: Bearer fixture-token' -H 'Content-Type: application/json' \ | ||
| 118 | -H 'Accept: application/json,text/event-stream' --data '{not-json' \ | ||
| 119 | "http://127.0.0.1:$MCP_PORT/mcp")" | ||
| 120 | [[ "$malformed_code" == 400 ]] | ||
| 121 | for invalid_request in \ | ||
| 122 | '[]' \ | ||
| 123 | '{"jsonrpc":"2.0","id":1,"method":"initialize","params":[]}' \ | ||
| 124 | '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":[]}}'; do | ||
| 125 | invalid_code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ | ||
| 126 | -H 'Authorization: Bearer fixture-token' -H 'Content-Type: application/json' \ | ||
| 127 | -H 'Accept: application/json,text/event-stream' --data "$invalid_request" \ | ||
| 128 | "http://127.0.0.1:$MCP_PORT/mcp")" | ||
| 129 | [[ "$invalid_code" == 400 ]] | ||
| 130 | done | ||
| 131 | invalid_length_code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ | ||
| 132 | -H 'Authorization: Bearer fixture-token' -H 'Content-Type: application/json' \ | ||
| 133 | -H 'Accept: application/json,text/event-stream' -H 'Content-Length: invalid' \ | ||
| 134 | --data '{}' "http://127.0.0.1:$MCP_PORT/mcp")" | ||
| 135 | [[ "$invalid_length_code" == 400 ]] | ||
| 136 | |||
| 137 | guest_token_dir="$tmp/guest-token/.executor/server-control" | ||
| 138 | mkdir -p "$guest_token_dir" | ||
| 139 | printf '{"token":"fixture-token"}\n' >"$guest_token_dir/auth.json" | ||
| 140 | # shellcheck disable=SC1090 | ||
| 141 | source "$PROJECT_ROOT/guest/hb" | ||
| 142 | TOKEN_FILE="$guest_token_dir/auth.json" | ||
| 143 | EXECUTOR_PORT="$MCP_PORT" | ||
| 144 | _mcp_auth_works | ||
| 145 | printf '{"token":"wrong-token"}\n' >"$guest_token_dir/auth.json" | ||
| 146 | if _mcp_auth_works; then | ||
| 147 | echo "guest MCP health accepted an invalid bearer token" >&2 | ||
| 148 | exit 1 | ||
| 149 | fi | ||
| 150 | if _port_free "$MCP_PORT"; then | ||
| 151 | echo "occupied TCP port was reported free" >&2 | ||
| 152 | exit 1 | ||
| 153 | fi | ||
| 154 | kill "$MCP_PID" | ||
| 155 | wait "$MCP_PID" 2>/dev/null || true | ||
| 156 | |||
| 157 | for unhealthy_status in 401 404 500; do | ||
| 158 | start_mcp_server "$unhealthy_status" "api-$unhealthy_status" | ||
| 159 | if _hermes_api_healthy "$MCP_PORT"; then | ||
| 160 | echo "Hermes API HTTP $unhealthy_status passed health" >&2 | ||
| 161 | exit 1 | ||
| 162 | fi | ||
| 163 | kill "$MCP_PID" | ||
| 164 | wait "$MCP_PID" 2>/dev/null || true | ||
| 165 | done | ||
| 166 | start_mcp_server 200 api-malformed health-malformed | ||
| 167 | if _hermes_api_healthy "$MCP_PORT"; then | ||
| 168 | echo "malformed Hermes API health JSON passed" >&2 | ||
| 169 | exit 1 | ||
| 170 | fi | ||
| 171 | kill "$MCP_PID" | ||
| 172 | wait "$MCP_PID" 2>/dev/null || true | ||
| 173 | |||
| 174 | if python3 -c 'import socket; s = socket.socket(socket.AF_INET6); s.bind(("::1", 0)); s.close()'; then | ||
| 175 | start_mcp_server 200 ipv6-only valid ::1 | ||
| 176 | if _port_free "$MCP_PORT"; then | ||
| 177 | echo "IPv6-only occupied TCP port was reported free" >&2 | ||
| 178 | exit 1 | ||
| 179 | fi | ||
| 180 | kill "$MCP_PID" | ||
| 181 | wait "$MCP_PID" 2>/dev/null || true | ||
| 182 | else | ||
| 183 | echo "IPv6 loopback unavailable; skipping IPv6 occupancy regression" >&2 | ||
| 184 | fi | ||
| 185 | |||
| 186 | start_mcp_server 200 malformed-json malformed-json | ||
| 187 | if _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token; then | ||
| 188 | echo "malformed initialize JSON passed MCP health" >&2 | ||
| 189 | exit 1 | ||
| 190 | fi | ||
| 191 | kill "$MCP_PID" | ||
| 192 | wait "$MCP_PID" 2>/dev/null || true | ||
| 193 | |||
| 194 | start_mcp_server 200 wrong-version wrong-version | ||
| 195 | if _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token; then | ||
| 196 | echo "wrong negotiated MCP version passed health" >&2 | ||
| 197 | exit 1 | ||
| 198 | fi | ||
| 199 | kill "$MCP_PID" | ||
| 200 | wait "$MCP_PID" 2>/dev/null || true | ||
| 201 | |||
| 202 | start_mcp_server 200 wrong-content-type wrong-content-type | ||
| 203 | if _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token; then | ||
| 204 | echo "wrong initialize Content-Type passed MCP health" >&2 | ||
| 205 | exit 1 | ||
| 206 | fi | ||
| 207 | kill "$MCP_PID" | ||
| 208 | wait "$MCP_PID" 2>/dev/null || true | ||
| 209 | |||
| 210 | start_mcp_server 200 event-stream event-stream | ||
| 211 | _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token | ||
| 212 | kill "$MCP_PID" | ||
| 213 | wait "$MCP_PID" 2>/dev/null || true | ||
| 214 | |||
| 215 | start_mcp_server 404 missing | ||
| 216 | if _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token; then | ||
| 217 | echo "HTTP 404 passed MCP health" >&2 | ||
| 218 | exit 1 | ||
| 219 | fi | ||
| 220 | kill "$MCP_PID" | ||
| 221 | wait "$MCP_PID" 2>/dev/null || true | ||
| 222 | |||
| 223 | start_mcp_server 500 broken | ||
| 224 | if _mcp_initialize "http://127.0.0.1:$MCP_PORT/mcp" fixture-token; then | ||
| 225 | echo "HTTP 500 passed MCP health" >&2 | ||
| 226 | exit 1 | ||
| 227 | fi | ||
| 228 | kill "$MCP_PID" | ||
| 229 | wait "$MCP_PID" 2>/dev/null || true | ||
| 230 | |||
| 231 | free_port="$MCP_PORT" | ||
| 232 | for ((attempt = 0; attempt < 100; attempt++)); do | ||
| 233 | _port_free "$free_port" && break | ||
| 234 | sleep 0.02 | ||
| 235 | done | ||
| 236 | _port_free "$free_port" | ||
| 237 | |||
| 238 | ready_attempts=0 | ||
| 239 | opener_attempt=0 | ||
| 240 | _mcp_initialize() { | ||
| 241 | ready_attempts=$((ready_attempts + 1)) | ||
| 242 | [[ "$ready_attempts" -ge 3 ]] | ||
| 243 | } | ||
| 244 | _open_url() { opener_attempt="$ready_attempts"; } | ||
| 245 | _wait_mcp_ready ignored ignored | ||
| 246 | _open_url 'https://fixture.invalid/secret' | ||
| 247 | [[ "$ready_attempts" == 3 && "$opener_attempt" == 3 ]] | ||
| 248 | ) | ||
| 249 | |||
| 250 | # Open refuses a quiesced box before hb up can clear its marker. | ||
| 251 | ( | ||
| 252 | # shellcheck disable=SC1090 | ||
| 253 | source "$PROJECT_ROOT/box" | ||
| 254 | calls="$tmp/open-quiesced.calls" | ||
| 255 | _host_preflight() { :; } | ||
| 256 | _acquire_lock() { :; } | ||
| 257 | _reg_port() { printf '4899\n'; } | ||
| 258 | _machine_running() { :; } | ||
| 259 | smolvm() { printf 'smolvm %s\n' "$*" >>"$calls"; } | ||
| 260 | _guest_hb() { | ||
| 261 | printf 'hb %s\n' "$2" >>"$calls" | ||
| 262 | [[ "$2" == is-paused ]] | ||
| 263 | } | ||
| 264 | if (cmd_open quiesced >/dev/null 2>&1); then | ||
| 265 | echo "box open unexpectedly succeeded while quiesced" >&2 | ||
| 266 | exit 1 | ||
| 267 | fi | ||
| 268 | grep -q 'hb is-paused' "$calls" | ||
| 269 | if grep -q 'hb up' "$calls"; then | ||
| 270 | echo "box open cleared quiesce through hb up" >&2 | ||
| 271 | exit 1 | ||
| 272 | fi | ||
| 273 | if grep -q 'machine start' "$calls"; then | ||
| 274 | echo "box open started the VM" >&2 | ||
| 275 | exit 1 | ||
| 276 | fi | ||
| 277 | ) | ||
| 278 | |||
| 279 | # Open refuses a stopped box and never changes its lifecycle state. | ||
| 280 | ( | ||
| 281 | # shellcheck disable=SC1090 | ||
| 282 | source "$PROJECT_ROOT/box" | ||
| 283 | calls="$tmp/open-stopped.calls" | ||
| 284 | _host_preflight() { :; } | ||
| 285 | _acquire_lock() { :; } | ||
| 286 | _reg_port() { printf '4899\n'; } | ||
| 287 | _machine_running() { return 1; } | ||
| 288 | smolvm() { printf 'smolvm %s\n' "$*" >>"$calls"; } | ||
| 289 | _guest_hb() { printf 'hb %s\n' "$2" >>"$calls"; } | ||
| 290 | if (cmd_open stopped >/dev/null 2>&1); then | ||
| 291 | echo "box open accepted a stopped box" >&2 | ||
| 292 | exit 1 | ||
| 293 | fi | ||
| 294 | [[ ! -e "$calls" ]] || ! grep -q 'machine start' "$calls" | ||
| 295 | ) | ||
| 296 | |||
| 297 | # Repair refreshes assets but preserves a pre-existing durable quiesce policy. | ||
| 298 | ( | ||
| 299 | # shellcheck disable=SC1090 | ||
| 300 | source "$PROJECT_ROOT/box" | ||
| 301 | calls="$tmp/repair-quiesced.calls" | ||
| 302 | _host_preflight() { :; } | ||
| 303 | _acquire_lock() { :; } | ||
| 304 | _machine_exists() { :; } | ||
| 305 | smolvm() { printf 'smolvm %s\n' "$*" >>"$calls"; } | ||
| 306 | _provision_into() { printf 'provision %s %s\n' "$1" "$2" >>"$calls"; } | ||
| 307 | _guest_hb() { | ||
| 308 | printf 'hb %s\n' "$2" >>"$calls" | ||
| 309 | [[ "$2" == is-paused || "$2" == doctor ]] | ||
| 310 | } | ||
| 311 | cmd_repair quiesced >/dev/null | ||
| 312 | grep -q 'provision quiesced full' "$calls" | ||
| 313 | grep -q 'hb doctor' "$calls" | ||
| 314 | if grep -Eq 'hb (up|resume)' "$calls"; then | ||
| 315 | echo "repair resumed a previously quiesced box" >&2 | ||
| 316 | exit 1 | ||
| 317 | fi | ||
| 318 | ) | ||
| 319 | |||
| 320 | # Repair validates a configured bundle before constructing the provision tar. | ||
| 321 | ( | ||
| 322 | # shellcheck disable=SC1090 | ||
| 323 | source "$PROJECT_ROOT/box" | ||
| 324 | calls="$tmp/repair-invalid-bundle.calls" | ||
| 325 | HERMES_GIT_BUNDLE='../invalid.bundle' | ||
| 326 | _host_preflight() { :; } | ||
| 327 | _acquire_lock() { :; } | ||
| 328 | _machine_exists() { :; } | ||
| 329 | _guest_hb() { [[ "$2" == is-paused ]]; } | ||
| 330 | smolvm() { printf '%s\n' "$*" >>"$calls"; } | ||
| 331 | if (cmd_repair invalid-bundle >/dev/null 2>&1); then | ||
| 332 | echo "repair accepted an invalid Hermes bundle path" >&2 | ||
| 333 | exit 1 | ||
| 334 | fi | ||
| 335 | if grep -q 'machine exec -i' "$calls"; then | ||
| 336 | echo "repair streamed provision context before bundle validation" >&2 | ||
| 337 | exit 1 | ||
| 338 | fi | ||
| 339 | ) | ||
| 340 | |||
| 341 | # Asset-only refreshes neither validate nor transport the optional source bundle. | ||
| 342 | ( | ||
| 343 | # shellcheck disable=SC1090 | ||
| 344 | source "$PROJECT_ROOT/box" | ||
| 345 | calls="$tmp/provision-mode.calls" | ||
| 346 | HERMES_GIT_BUNDLE='artifacts/hermes.bundle' | ||
| 347 | _validate_bundle() { printf 'validate\n' >>"$calls"; } | ||
| 348 | tar() { printf 'tar %s\n' "$*" >>"$calls"; printf 'fixture'; } | ||
| 349 | smolvm() { if [[ "$*" == *'machine exec -i'* ]]; then cat >/dev/null; fi; } | ||
| 350 | _provision_into fixture assets >/dev/null | ||
| 351 | [[ ! -e "$calls" ]] || ! grep -q '^validate$\|artifacts/hermes.bundle' "$calls" | ||
| 352 | _provision_into fixture full >/dev/null | ||
| 353 | grep -q '^validate$' "$calls" | ||
| 354 | grep -q 'artifacts/hermes.bundle' "$calls" | ||
| 355 | ) | ||
| 356 | |||
| 357 | echo "doctor-mcp regression checks passed" | ||
| @@ -1,326 +0,0 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # shellcheck disable=SC2016,SC2030,SC2031,SC2329 | ||
| 3 | set -euo pipefail | ||
| 4 | |||
| 5 | # shellcheck source=tests/lib.sh | ||
| 6 | source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" | ||
| 7 | |||
| 8 | mkdir -p "$tmp/valid/home/agent/workspace" "$tmp/gnupg" "$tmp/host-tmp" | ||
| 9 | chmod 0700 "$tmp/gnupg" | ||
| 10 | printf 'portable state\n' >"$tmp/valid/home/agent/workspace/probe.txt" | ||
| 11 | tar czf "$tmp/valid.tgz" -C "$tmp/valid" . | ||
| 12 | export GNUPGHOME="$tmp/gnupg" | ||
| 13 | export BOX_PASSPHRASE='regression-passphrase' | ||
| 14 | gpg --batch --yes --pinentry-mode loopback --passphrase "$BOX_PASSPHRASE" \ | ||
| 15 | --symmetric --cipher-algo AES256 -o "$tmp/valid.tar.gz.gpg" "$tmp/valid.tgz" | ||
| 16 | |||
| 17 | # Legacy registry rows remain readable and reserve their Executor port. | ||
| 18 | ( | ||
| 19 | # shellcheck disable=SC1090 | ||
| 20 | source "$PROJECT_ROOT/box" | ||
| 21 | REG="$tmp/legacy-registry" | ||
| 22 | printf 'legacy\t4900\n' >"$REG" | ||
| 23 | _reg_exists legacy | ||
| 24 | [[ "$(_reg_port legacy)" == 4900 ]] | ||
| 25 | [[ -z "$(_reg_api_port legacy)" ]] | ||
| 26 | if _port_free 4900; then | ||
| 27 | echo "legacy registry port was reported free" >&2 | ||
| 28 | exit 1 | ||
| 29 | fi | ||
| 30 | ) | ||
| 31 | |||
| 32 | # A failed precheck must preserve a same-named unmanaged smolvm machine. | ||
| 33 | preexisting_repo="$tmp/preexisting-repo" | ||
| 34 | make_repo "$preexisting_repo" | ||
| 35 | mkdir -p "$tmp/preexisting-state/owned-elsewhere" | ||
| 36 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/preexisting.calls" \ | ||
| 37 | SMOLVM_STATE_DIR="$tmp/preexisting-state" SMOLVM_BEHAVIOR=success \ | ||
| 38 | "$preexisting_repo/box" new owned-elsewhere >/dev/null 2>&1; then | ||
| 39 | echo "creation over a preexisting machine unexpectedly succeeded" >&2 | ||
| 40 | exit 1 | ||
| 41 | fi | ||
| 42 | [[ -d "$tmp/preexisting-state/owned-elsewhere" ]] | ||
| 43 | |||
| 44 | # If create reports failure after another machine appears, ownership is unknown | ||
| 45 | # and cleanup must preserve that race winner. | ||
| 46 | race_repo="$tmp/race-repo" | ||
| 47 | make_repo "$race_repo" | ||
| 48 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/race.calls" \ | ||
| 49 | SMOLVM_STATE_DIR="$tmp/race-state" SMOLVM_BEHAVIOR=create-race \ | ||
| 50 | "$race_repo/box" new race-winner >/dev/null 2>&1; then | ||
| 51 | echo "ambiguous create race unexpectedly succeeded" >&2 | ||
| 52 | exit 1 | ||
| 53 | fi | ||
| 54 | [[ -d "$tmp/race-state/race-winner" ]] | ||
| 55 | if grep -q 'machine delete' "$tmp/race.calls"; then | ||
| 56 | echo "ambiguous create race winner was deleted" >&2 | ||
| 57 | exit 1 | ||
| 58 | fi | ||
| 59 | |||
| 60 | # TERM during provisioning rolls back the invocation-owned VM and registry. | ||
| 61 | signal_repo="$tmp/signal-repo" | ||
| 62 | make_repo "$signal_repo" | ||
| 63 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/signal.calls" \ | ||
| 64 | SMOLVM_STATE_DIR="$tmp/signal-state" SMOLVM_BEHAVIOR=block-provision \ | ||
| 65 | "$signal_repo/box" new signal-probe >/dev/null 2>&1; then | ||
| 66 | echo "signal-interrupted creation unexpectedly succeeded" >&2 | ||
| 67 | exit 1 | ||
| 68 | fi | ||
| 69 | [[ ! -d "$tmp/signal-state/signal-probe" ]] | ||
| 70 | [[ ! -s "$signal_repo/.boxes" ]] | ||
| 71 | |||
| 72 | # A signal delivered while create itself succeeds is deferred until ownership | ||
| 73 | # and registry metadata are recorded, then cleanup safely removes the owned VM. | ||
| 74 | create_signal_repo="$tmp/create-signal-repo" | ||
| 75 | make_repo "$create_signal_repo" | ||
| 76 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/create-signal.calls" \ | ||
| 77 | SMOLVM_STATE_DIR="$tmp/create-signal-state" SMOLVM_BEHAVIOR=signal-create-success \ | ||
| 78 | "$create_signal_repo/box" new create-signal-probe >/dev/null 2>&1; then | ||
| 79 | echo "create-time signal unexpectedly succeeded" >&2 | ||
| 80 | exit 1 | ||
| 81 | fi | ||
| 82 | [[ ! -d "$tmp/create-signal-state/create-signal-probe" ]] | ||
| 83 | [[ ! -s "$create_signal_repo/.boxes" ]] | ||
| 84 | grep -q 'machine create' "$tmp/create-signal.calls" | ||
| 85 | grep -q -- '--cpus 4 --mem 8192 --overlay 64' "$tmp/create-signal.calls" | ||
| 86 | grep -q 'machine delete' "$tmp/create-signal.calls" | ||
| 87 | if grep -q 'machine start' "$tmp/create-signal.calls"; then | ||
| 88 | echo "create-time signal was not honored before start" >&2 | ||
| 89 | exit 1 | ||
| 90 | fi | ||
| 91 | |||
| 92 | # Optional Hermes API exposure constructs a distinct loopback forward and | ||
| 93 | # passes its guest bridge port to the persistent workload. | ||
| 94 | api_repo="$tmp/api-repo" | ||
| 95 | make_repo "$api_repo" | ||
| 96 | printf '\nEXPOSE_HERMES_API=1\nHERMES_API_PORT_LO=8650\nHERMES_API_PORT_HI=8650\n' >>"$api_repo/box.env" | ||
| 97 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/api.calls" \ | ||
| 98 | SMOLVM_STATE_DIR="$tmp/api-state" SMOLVM_BEHAVIOR=fail-exec \ | ||
| 99 | "$api_repo/box" new api-probe >/dev/null 2>&1; then | ||
| 100 | echo "mock API provisioning failure unexpectedly succeeded" >&2 | ||
| 101 | exit 1 | ||
| 102 | fi | ||
| 103 | grep -q -- '-p 8650:8650' "$tmp/api.calls" | ||
| 104 | grep -q 'hb-workload.*8650' "$tmp/api.calls" | ||
| 105 | |||
| 106 | # Native import fails closed. External provider state requires explicit consent | ||
| 107 | # before any VM command, and failures/signals after disable never restart it. | ||
| 108 | python3 - "$tmp/hermes-import.zip" <<'PY' | ||
| 109 | import sys, zipfile | ||
| 110 | with zipfile.ZipFile(sys.argv[1], "w") as archive: | ||
| 111 | archive.writestr("config.yaml", "model: fixture\n") | ||
| 112 | archive.writestr(".env", "DISCORD_BOT_TOKEN=fixture\n") | ||
| 113 | archive.writestr("tmp/transient.log", "do not migrate\n") | ||
| 114 | archive.writestr("_external/.honcho/config.json", "{}\n") | ||
| 115 | PY | ||
| 116 | import_repo="$tmp/import-repo" | ||
| 117 | make_repo "$import_repo" | ||
| 118 | mkdir -p "$tmp/import-state/import-probe" | ||
| 119 | printf 'import-probe\t\n' >"$import_repo/.boxes" | ||
| 120 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/import-no-consent.calls" \ | ||
| 121 | SMOLVM_STATE_DIR="$tmp/import-state" SMOLVM_BEHAVIOR=success \ | ||
| 122 | "$import_repo/box" import-hermes import-probe "$tmp/hermes-import.zip" >/dev/null 2>&1; then | ||
| 123 | echo "external import without consent unexpectedly succeeded" >&2 | ||
| 124 | exit 1 | ||
| 125 | fi | ||
| 126 | [[ ! -e "$tmp/import-no-consent.calls" ]] | ||
| 127 | |||
| 128 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/import-fail.calls" \ | ||
| 129 | SMOLVM_STATE_DIR="$tmp/import-state" SMOLVM_BEHAVIOR=import-post-fail \ | ||
| 130 | "$import_repo/box" import-hermes import-probe "$tmp/hermes-import.zip" \ | ||
| 131 | --external .honcho >/dev/null 2>&1; then | ||
| 132 | echo "post-import verification failure unexpectedly succeeded" >&2 | ||
| 133 | exit 1 | ||
| 134 | fi | ||
| 135 | [[ -e "$tmp/import-state/import-probe/gateway-disabled" ]] | ||
| 136 | [[ ! -e "$tmp/import-state/import-probe/gateway-restarted" ]] | ||
| 137 | grep -q 'hb gateway-disable' "$tmp/import-fail.calls" | ||
| 138 | grep -q 'hb resume' "$tmp/import-fail.calls" | ||
| 139 | grep -q '/data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls" | ||
| 140 | if grep -q '/root/hermes-import-' "$tmp/import-fail.calls"; then | ||
| 141 | echo "Hermes import still stages beneath root-only storage" >&2 | ||
| 142 | exit 1 | ||
| 143 | fi | ||
| 144 | import_dir_line="$(grep -n 'install -d -o agent -g agent -m 0700 /data/home/agent/.config/hermes-box' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)" | ||
| 145 | import_cp_line="$(grep -n 'machine cp.*\.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)" | ||
| 146 | import_chown_line="$(grep -n 'chown agent:agent /data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)" | ||
| 147 | import_chmod_line="$(grep -n 'chmod 0600 /data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)" | ||
| 148 | import_setpriv_line="$(grep -n 'setpriv.*hermes-state.*import-zip.*/data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)" | ||
| 149 | [[ -n "$import_dir_line" && -n "$import_cp_line" && -n "$import_chown_line" && -n "$import_chmod_line" && -n "$import_setpriv_line" ]] | ||
| 150 | [[ "$import_dir_line" -lt "$import_cp_line" && "$import_cp_line" -lt "$import_chown_line" && "$import_chown_line" -lt "$import_chmod_line" && "$import_chmod_line" -lt "$import_setpriv_line" ]] | ||
| 151 | import_remove_line="$(grep -n 'rm -f -- /data/home/agent/.config/hermes-box/import-' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)" | ||
| 152 | import_resume_line="$(grep -n 'hb resume' "$tmp/import-fail.calls" | head -1 | cut -d: -f1)" | ||
| 153 | [[ -n "$import_remove_line" && -n "$import_resume_line" && "$import_remove_line" -lt "$import_resume_line" ]] | ||
| 154 | |||
| 155 | rm -f "$tmp/import-state/import-probe/paused" | ||
| 156 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/import-signal.calls" \ | ||
| 157 | SMOLVM_STATE_DIR="$tmp/import-state" SMOLVM_BEHAVIOR=signal-import \ | ||
| 158 | "$import_repo/box" import-hermes import-probe "$tmp/hermes-import.zip" \ | ||
| 159 | --external .honcho >/dev/null 2>&1; then | ||
| 160 | echo "signal-interrupted import unexpectedly succeeded" >&2 | ||
| 161 | exit 1 | ||
| 162 | fi | ||
| 163 | [[ -e "$tmp/import-state/import-probe/gateway-disabled" ]] | ||
| 164 | [[ ! -e "$tmp/import-state/import-probe/gateway-restarted" ]] | ||
| 165 | grep -q 'hb resume' "$tmp/import-signal.calls" | ||
| 166 | signal_remove_line="$(grep -n 'rm -f -- /data/home/agent/.config/hermes-box/import-' "$tmp/import-signal.calls" | head -1 | cut -d: -f1)" | ||
| 167 | signal_resume_line="$(grep -n 'hb resume' "$tmp/import-signal.calls" | head -1 | cut -d: -f1)" | ||
| 168 | [[ -n "$signal_remove_line" && -n "$signal_resume_line" && "$signal_remove_line" -lt "$signal_resume_line" ]] | ||
| 169 | |||
| 170 | # Import temporarily starts a stopped target, preserves its unpaused policy, | ||
| 171 | # keeps the gateway disabled, and returns it to stopped on success or failure. | ||
| 172 | for stopped_case in success failure; do | ||
| 173 | stopped_name="stopped-import-$stopped_case" | ||
| 174 | mkdir -p "$tmp/import-state/$stopped_name" | ||
| 175 | printf '%s\t\n' "$stopped_name" >>"$import_repo/.boxes" | ||
| 176 | behavior=inactive | ||
| 177 | [[ "$stopped_case" == success ]] || behavior=inactive-import-post-fail | ||
| 178 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/${stopped_name}.calls" \ | ||
| 179 | SMOLVM_STATE_DIR="$tmp/import-state" SMOLVM_BEHAVIOR="$behavior" \ | ||
| 180 | "$import_repo/box" import-hermes "$stopped_name" "$tmp/hermes-import.zip" \ | ||
| 181 | --external .honcho >"$tmp/${stopped_name}.output" 2>&1; then | ||
| 182 | [[ "$stopped_case" == success ]] || { | ||
| 183 | echo "stopped import failure case unexpectedly succeeded" >&2; exit 1; | ||
| 184 | } | ||
| 185 | else | ||
| 186 | [[ "$stopped_case" == failure ]] || { | ||
| 187 | echo "stopped import success case failed" >&2; exit 1; | ||
| 188 | } | ||
| 189 | fi | ||
| 190 | grep -q 'machine start' "$tmp/${stopped_name}.calls" | ||
| 191 | grep -q 'machine stop' "$tmp/${stopped_name}.calls" | ||
| 192 | grep -q 'hb gateway-disable' "$tmp/${stopped_name}.calls" | ||
| 193 | [[ -e "$tmp/import-state/$stopped_name/gateway-disabled" ]] | ||
| 194 | [[ ! -e "$tmp/import-state/$stopped_name/paused" ]] | ||
| 195 | [[ ! -e "$tmp/import-state/$stopped_name/gateway-restarted" ]] | ||
| 196 | done | ||
| 197 | grep -q 'Native import will intentionally skip 1 non-portable or transient file(s)' \ | ||
| 198 | "$tmp/stopped-import-success.output" | ||
| 199 | grep -q 'Hermes import verified. Gateway remains disabled' "$tmp/stopped-import-success.output" | ||
| 200 | |||
| 201 | # Pause may have taken effect even when the pause command reports failure. The | ||
| 202 | # existing box is tracked early and resumed during cleanup. | ||
| 203 | pause_repo="$tmp/pause-repo" | ||
| 204 | make_repo "$pause_repo" | ||
| 205 | mkdir -p "$tmp/pause-state/pause-probe" | ||
| 206 | printf 'pause-probe\t\n' >"$pause_repo/.boxes" | ||
| 207 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/pause.calls" \ | ||
| 208 | SMOLVM_STATE_DIR="$tmp/pause-state" SMOLVM_BEHAVIOR=pause-fail \ | ||
| 209 | "$pause_repo/box" save pause-probe "$tmp/pause.gpg" >/dev/null 2>&1; then | ||
| 210 | echo "failed pause unexpectedly allowed save" >&2 | ||
| 211 | exit 1 | ||
| 212 | fi | ||
| 213 | grep -q 'hb resume' "$tmp/pause.calls" | ||
| 214 | [[ ! -e "$tmp/pause-state/pause-probe/paused" ]] | ||
| 215 | |||
| 216 | # Rollback remains best-effort when another process owns the registry lock: VM | ||
| 217 | # deletion and operation-lock cleanup still happen, while metadata is preserved. | ||
| 218 | contention_repo="$tmp/contention-repo" | ||
| 219 | make_repo "$contention_repo" | ||
| 220 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/contention.calls" \ | ||
| 221 | SMOLVM_STATE_DIR="$tmp/contention-state" SMOLVM_BEHAVIOR=registry-contention \ | ||
| 222 | SMOLVM_REGISTRY_LOCK_DIR="$contention_repo/.box-locks/registry.lock" \ | ||
| 223 | SMOLVM_REGISTRY_LOCK_OWNER_PID="$$" \ | ||
| 224 | "$contention_repo/box" new contention-probe >/dev/null 2>&1; then | ||
| 225 | echo "registry-contention provisioning unexpectedly succeeded" >&2 | ||
| 226 | exit 1 | ||
| 227 | fi | ||
| 228 | [[ ! -d "$tmp/contention-state/contention-probe" ]] | ||
| 229 | grep -q '^contention-probe' "$contention_repo/.boxes" | ||
| 230 | [[ ! -e "$contention_repo/.box-locks/contention-probe.lock" ]] | ||
| 231 | |||
| 232 | # Rollback recovers a stale registry lock without aborting best-effort cleanup. | ||
| 233 | stale_lock_repo="$tmp/stale-lock-repo" | ||
| 234 | make_repo "$stale_lock_repo" | ||
| 235 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/stale-lock.calls" \ | ||
| 236 | SMOLVM_STATE_DIR="$tmp/stale-lock-state" SMOLVM_BEHAVIOR=stale-registry-lock \ | ||
| 237 | SMOLVM_REGISTRY_LOCK_DIR="$stale_lock_repo/.box-locks/registry.lock" \ | ||
| 238 | "$stale_lock_repo/box" new stale-lock-probe >/dev/null 2>&1; then | ||
| 239 | echo "stale-lock provisioning unexpectedly succeeded" >&2 | ||
| 240 | exit 1 | ||
| 241 | fi | ||
| 242 | [[ ! -d "$tmp/stale-lock-state/stale-lock-probe" ]] | ||
| 243 | [[ ! -s "$stale_lock_repo/.boxes" ]] | ||
| 244 | [[ ! -e "$stale_lock_repo/.box-locks/registry.lock" ]] | ||
| 245 | [[ ! -e "$stale_lock_repo/.box-locks/stale-lock-probe.lock" ]] | ||
| 246 | |||
| 247 | # A failed delete preserves both the incomplete machine and its registry record. | ||
| 248 | delete_repo="$tmp/delete-repo" | ||
| 249 | make_repo "$delete_repo" | ||
| 250 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/delete.calls" \ | ||
| 251 | SMOLVM_STATE_DIR="$tmp/delete-state" SMOLVM_BEHAVIOR=delete-fail \ | ||
| 252 | "$delete_repo/box" new delete-probe >/dev/null 2>&1; then | ||
| 253 | echo "delete-failure provisioning unexpectedly succeeded" >&2 | ||
| 254 | exit 1 | ||
| 255 | fi | ||
| 256 | [[ -d "$tmp/delete-state/delete-probe" ]] | ||
| 257 | grep -q '^delete-probe' "$delete_repo/.boxes" | ||
| 258 | |||
| 259 | # A delete error plus a generic status error is never interpreted as absence. | ||
| 260 | delete_status_repo="$tmp/delete-status-repo" | ||
| 261 | make_repo "$delete_status_repo" | ||
| 262 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/delete-status.calls" \ | ||
| 263 | SMOLVM_STATE_DIR="$tmp/delete-status-state" SMOLVM_BEHAVIOR=delete-status-fail \ | ||
| 264 | "$delete_status_repo/box" new delete-status-probe >/dev/null 2>&1; then | ||
| 265 | echo "delete/status-failure provisioning unexpectedly succeeded" >&2 | ||
| 266 | exit 1 | ||
| 267 | fi | ||
| 268 | [[ -d "$tmp/delete-status-state/delete-status-probe" ]] | ||
| 269 | grep -q '^delete-status-probe' "$delete_status_repo/.boxes" | ||
| 270 | |||
| 271 | # Registration happens before start, so a start failure plus delete failure | ||
| 272 | # leaves enough host metadata for explicit recovery. | ||
| 273 | start_delete_repo="$tmp/start-delete-repo" | ||
| 274 | make_repo "$start_delete_repo" | ||
| 275 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/start-delete.calls" \ | ||
| 276 | SMOLVM_STATE_DIR="$tmp/start-delete-state" SMOLVM_BEHAVIOR=start-delete-fail \ | ||
| 277 | "$start_delete_repo/box" new start-delete-probe >/dev/null 2>&1; then | ||
| 278 | echo "start/delete failure unexpectedly succeeded" >&2 | ||
| 279 | exit 1 | ||
| 280 | fi | ||
| 281 | [[ -d "$tmp/start-delete-state/start-delete-probe" ]] | ||
| 282 | grep -q '^start-delete-probe' "$start_delete_repo/.boxes" | ||
| 283 | |||
| 284 | # Save failures remove the guest plaintext before resuming Executor. | ||
| 285 | save_repo="$tmp/save-repo" | ||
| 286 | make_repo "$save_repo" | ||
| 287 | mkdir -p "$tmp/save-state/save-probe" | ||
| 288 | printf 'save-probe\t\n' >"$save_repo/.boxes" | ||
| 289 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/save.calls" \ | ||
| 290 | SMOLVM_STATE_DIR="$tmp/save-state" SMOLVM_BEHAVIOR=save-cp-fail \ | ||
| 291 | SMOLVM_ARCHIVE_SOURCE="$tmp/valid.tgz" TMPDIR="$tmp/host-tmp" \ | ||
| 292 | "$save_repo/box" save save-probe "$tmp/save.gpg" >/dev/null 2>&1; then | ||
| 293 | echo "save copy failure unexpectedly succeeded" >&2 | ||
| 294 | exit 1 | ||
| 295 | fi | ||
| 296 | grep -q '/root/hb-data-' "$tmp/save.calls" | ||
| 297 | remove_line="$(grep -n 'rm -f -- /root/hb-data-' "$tmp/save.calls" | head -1 | cut -d: -f1)" | ||
| 298 | resume_line="$(grep -n 'hb resume' "$tmp/save.calls" | head -1 | cut -d: -f1)" | ||
| 299 | [[ -n "$remove_line" && -n "$resume_line" && "$remove_line" -lt "$resume_line" ]] | ||
| 300 | [[ -z "$(find "$tmp/host-tmp" -mindepth 1 -maxdepth 1 -print -quit)" ]] | ||
| 301 | |||
| 302 | # Restore extraction failures propagate, delete the incomplete VM, and contain | ||
| 303 | # the fail-fast extraction and guest-temp trap in the invoked command. | ||
| 304 | restore_repo="$tmp/restore-repo" | ||
| 305 | make_repo "$restore_repo" | ||
| 306 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/restore.calls" \ | ||
| 307 | SMOLVM_STATE_DIR="$tmp/restore-state" SMOLVM_BEHAVIOR=restore-tar-fail \ | ||
| 308 | "$restore_repo/box" load "$tmp/valid.tar.gz.gpg" restore-probe >/dev/null 2>&1; then | ||
| 309 | echo "restore tar failure unexpectedly succeeded" >&2 | ||
| 310 | exit 1 | ||
| 311 | fi | ||
| 312 | [[ ! -d "$tmp/restore-state/restore-probe" ]] | ||
| 313 | if grep -q 'hb resume' "$tmp/restore.calls"; then | ||
| 314 | echo "failed restore resumed Executor before deleting its new VM" >&2 | ||
| 315 | exit 1 | ||
| 316 | fi | ||
| 317 | grep -q 'machine delete' "$tmp/restore.calls" | ||
| 318 | grep -q 'chmod 0600 /root/hb-restore-' "$tmp/restore.calls" | ||
| 319 | restore_remove_line="$(grep -n 'rm -f -- /root/hb-restore-' "$tmp/restore.calls" | head -1 | cut -d: -f1)" | ||
| 320 | restore_delete_line="$(grep -n 'machine delete' "$tmp/restore.calls" | head -1 | cut -d: -f1)" | ||
| 321 | [[ -n "$restore_remove_line" && -n "$restore_delete_line" && "$restore_remove_line" -lt "$restore_delete_line" ]] | ||
| 322 | grep -q 'set -euo pipefail' "$tmp/restore.calls" | ||
| 323 | grep -q 'trap.*rm -f --.*archive' "$tmp/restore.calls" | ||
| 324 | |||
| 325 | |||
| 326 | echo "lock-rollback regression checks passed" | ||
| @@ -1,157 +0,0 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # shellcheck disable=SC2016,SC2030,SC2031,SC2329 | ||
| 3 | set -euo pipefail | ||
| 4 | |||
| 5 | # shellcheck source=tests/lib.sh | ||
| 6 | source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" | ||
| 7 | |||
| 8 | # Basic VM operations do not require HTTP health tools; health operations do. | ||
| 9 | ( | ||
| 10 | # shellcheck disable=SC1090 | ||
| 11 | source "$PROJECT_ROOT/box" | ||
| 12 | have() { [[ "$1" == awk || "$1" == smolvm ]]; } | ||
| 13 | _host_preflight basic | ||
| 14 | ) | ||
| 15 | for missing_health_tool in curl jq mktemp; do | ||
| 16 | if ( | ||
| 17 | # shellcheck disable=SC1090 | ||
| 18 | source "$PROJECT_ROOT/box" | ||
| 19 | have() { [[ "$1" != "$missing_health_tool" ]]; } | ||
| 20 | _host_preflight health | ||
| 21 | ) 2>/dev/null; then | ||
| 22 | echo "health preflight passed without $missing_health_tool" >&2 | ||
| 23 | exit 1 | ||
| 24 | fi | ||
| 25 | done | ||
| 26 | |||
| 27 | # Scheduled host health gates on an already-running box and never starts one. | ||
| 28 | host_root="$tmp/tx9-host-root" | ||
| 29 | mkdir -p "$host_root" "$tmp/tx9-host-state/scheduled" | ||
| 30 | printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >>"${TX9_BOX_CALLS:?}"\n' >"$host_root/box" | ||
| 31 | chmod +x "$host_root/box" | ||
| 32 | if PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/tx9-inactive.smolvm" \ | ||
| 33 | SMOLVM_STATE_DIR="$tmp/tx9-host-state" SMOLVM_BEHAVIOR=inactive \ | ||
| 34 | SMOLVM_CALLED_UNUSED=1 TX9_ROOT="$host_root" TX9_BOX_CALLS="$tmp/tx9-inactive.box" \ | ||
| 35 | "$PROJECT_ROOT/ops/tx9-host" health scheduled >/dev/null 2>&1; then | ||
| 36 | echo "scheduled health accepted an inactive box" >&2 | ||
| 37 | exit 1 | ||
| 38 | fi | ||
| 39 | [[ ! -e "$tmp/tx9-inactive.box" ]] | ||
| 40 | if grep -q 'machine start' "$tmp/tx9-inactive.smolvm"; then | ||
| 41 | echo "scheduled health started an inactive box" >&2 | ||
| 42 | exit 1 | ||
| 43 | fi | ||
| 44 | PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/tx9-active.smolvm" \ | ||
| 45 | SMOLVM_STATE_DIR="$tmp/tx9-host-state" SMOLVM_BEHAVIOR=success \ | ||
| 46 | TX9_ROOT="$host_root" TX9_BOX_CALLS="$tmp/tx9-active.box" \ | ||
| 47 | "$PROJECT_ROOT/ops/tx9-host" health scheduled >/dev/null | ||
| 48 | grep -q '^doctor scheduled$' "$tmp/tx9-active.box" | ||
| 49 | |||
| 50 | # Sourced configuration cannot redirect an operation to another box name. | ||
| 51 | mkdir -p "$tmp/tx9-config/boxes" | ||
| 52 | printf 'name=global-override\nTX9_BOX_CONFIG_DIR=%s\n' "$tmp/tx9-config/boxes" >"$tmp/tx9-config/global.conf" | ||
| 53 | printf 'name=box-override\n' >"$tmp/tx9-config/boxes/scheduled.conf" | ||
| 54 | PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/tx9-config-name.smolvm" \ | ||
| 55 | SMOLVM_STATE_DIR="$tmp/tx9-host-state" SMOLVM_BEHAVIOR=success \ | ||
| 56 | TX9_CONFIG="$tmp/tx9-config/global.conf" TX9_ROOT="$host_root" \ | ||
| 57 | TX9_BOX_CALLS="$tmp/tx9-config-name.box" \ | ||
| 58 | "$PROJECT_ROOT/ops/tx9-host" health scheduled >/dev/null | ||
| 59 | [[ "$(cat "$tmp/tx9-config-name.box")" == 'doctor scheduled' ]] | ||
| 60 | |||
| 61 | # The foreground supervisor starts once and restarts an unexpectedly stopped VM. | ||
| 62 | PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/tx9-supervise.smolvm" \ | ||
| 63 | SMOLVM_STATE_DIR="$tmp/tx9-host-state" SMOLVM_BEHAVIOR=inactive \ | ||
| 64 | TX9_ROOT="$host_root" TX9_BOX_CALLS="$tmp/tx9-supervise.box" TX9_SUPERVISE_ONCE=1 \ | ||
| 65 | "$PROJECT_ROOT/ops/tx9-host" supervise scheduled >/dev/null 2>&1 | ||
| 66 | [[ "$(grep -c '^start scheduled$' "$tmp/tx9-supervise.box")" == 2 ]] | ||
| 67 | |||
| 68 | # A supervisor that receives stop intent exits without probing or restarting. | ||
| 69 | PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/tx9-supervise-stop.smolvm" \ | ||
| 70 | SMOLVM_STATE_DIR="$tmp/tx9-host-state" SMOLVM_BEHAVIOR=inactive \ | ||
| 71 | TX9_ROOT="$host_root" TX9_BOX_CALLS="$tmp/tx9-supervise-stop.box" \ | ||
| 72 | TX9_SUPERVISE_INTERVAL_SECONDS=300 \ | ||
| 73 | "$PROJECT_ROOT/ops/tx9-host" supervise scheduled >/dev/null 2>&1 & | ||
| 74 | supervisor_pid=$! | ||
| 75 | pids+=("$supervisor_pid") | ||
| 76 | wait_for_pattern '^start scheduled$' "$tmp/tx9-supervise-stop.box" | ||
| 77 | kill -TERM "$supervisor_pid" | ||
| 78 | wait "$supervisor_pid" | ||
| 79 | [[ "$(grep -c '^start scheduled$' "$tmp/tx9-supervise-stop.box")" == 1 ]] | ||
| 80 | [[ ! -e "$tmp/tx9-supervise-stop.smolvm" ]] | ||
| 81 | |||
| 82 | # An unset systemd credential directory does not resolve to /backup-passphrase. | ||
| 83 | if env -u BOX_PASSPHRASE_FILE -u CREDENTIALS_DIRECTORY \ | ||
| 84 | PATH="$PROJECT_ROOT/tests/fixtures:$PATH" SMOLVM_CALLED="$tmp/tx9-no-credential.smolvm" \ | ||
| 85 | SMOLVM_STATE_DIR="$tmp/tx9-host-state" SMOLVM_BEHAVIOR=success \ | ||
| 86 | TX9_ROOT="$host_root" TX9_BOX_CALLS="$tmp/tx9-no-credential.box" \ | ||
| 87 | "$PROJECT_ROOT/ops/tx9-host" backup scheduled >/dev/null 2>&1; then | ||
| 88 | echo "scheduled backup accepted a missing credential" >&2 | ||
| 89 | exit 1 | ||
| 90 | fi | ||
| 91 | [[ ! -e "$tmp/tx9-no-credential.box" ]] || ! grep -q '^save ' "$tmp/tx9-no-credential.box" | ||
| 92 | |||
| 93 | # Credential files without a trailing newline are accepted intact. | ||
| 94 | printf 'no-newline-passphrase' >"$tmp/passphrase-no-newline" | ||
| 95 | ( | ||
| 96 | # shellcheck disable=SC1090 | ||
| 97 | source "$PROJECT_ROOT/box" | ||
| 98 | _BOX_PASS="" | ||
| 99 | BOX_PASSPHRASE_FILE="$tmp/passphrase-no-newline" | ||
| 100 | _resolve_passphrase | ||
| 101 | [[ "$_BOX_PASS" == no-newline-passphrase ]] | ||
| 102 | ) | ||
| 103 | |||
| 104 | # tx9-backup-prune keeps the newest TX9_BACKUP_RETAIN_COUNT backups, applies | ||
| 105 | # an optional TX9_BACKUP_RETAIN_DAYS cap on top, leaves a missing NAS | ||
| 106 | # directory alone, never touches another box's backups, and validates input. | ||
| 107 | # | ||
| 108 | # CRITICAL ISOLATION: the script sources /etc/tx9/tx9.conf and the per-box | ||
| 109 | # conf AFTER the environment, so a real host config overrides the test's | ||
| 110 | # TX9_BACKUP_DIR — on a production host, an unisolated run points the prune | ||
| 111 | # at the REAL backup directory and (given permissions) deletes real backups. | ||
| 112 | # Observed live on nexus: /etc/tx9/boxes/hermes.conf redirected this test to | ||
| 113 | # /var/backups/tx9; only a permission error stopped it. Point both config | ||
| 114 | # knobs at test-owned paths for every invocation below. | ||
| 115 | export TX9_CONFIG="/dev/null" | ||
| 116 | export TX9_BOX_CONFIG_DIR="$tmp/prune-no-box-config" | ||
| 117 | prune_dir="$tmp/prune-backups" | ||
| 118 | mkdir -p "$prune_dir" | ||
| 119 | for day in 10 9 8 7 6 5 4 3 2 1; do | ||
| 120 | stamp="$(date -u -d "-${day} days" +%Y%m%d-%H%M%SZ)" | ||
| 121 | : >"$prune_dir/hermes-${stamp}.tar.gz.gpg" | ||
| 122 | : >"$prune_dir/hermes-${stamp}.tar.gz.gpg.sha256" | ||
| 123 | done | ||
| 124 | : >"$prune_dir/other-$(date -u -d '-1 days' +%Y%m%d-%H%M%SZ).tar.gz.gpg" | ||
| 125 | : >"$prune_dir/other-$(date -u -d '-1 days' +%Y%m%d-%H%M%SZ).tar.gz.gpg.sha256" | ||
| 126 | |||
| 127 | TX9_BACKUP_DIR="$prune_dir" TX9_BACKUP_RETAIN_COUNT=3 \ | ||
| 128 | "$PROJECT_ROOT/ops/tx9-backup-prune" hermes >/dev/null | ||
| 129 | [[ "$(find "$prune_dir" -maxdepth 1 -name 'hermes-*.tar.gz.gpg' | wc -l)" == 3 ]] | ||
| 130 | [[ "$(find "$prune_dir" -maxdepth 1 -name 'other-*.tar.gz.gpg' | wc -l)" == 1 ]] | ||
| 131 | # The 3 survivors are the 3 newest (days 1-3 back); each kept .tar.gz.gpg | ||
| 132 | # still has its matching .sha256 companion. | ||
| 133 | for day in 1 2 3; do | ||
| 134 | stamp="$(date -u -d "-${day} days" +%Y%m%d-%H%M%SZ)" | ||
| 135 | [[ -e "$prune_dir/hermes-${stamp}.tar.gz.gpg" ]] | ||
| 136 | [[ -e "$prune_dir/hermes-${stamp}.tar.gz.gpg.sha256" ]] | ||
| 137 | done | ||
| 138 | |||
| 139 | TX9_BACKUP_DIR="$prune_dir" TX9_BACKUP_RETAIN_COUNT=3 TX9_BACKUP_RETAIN_DAYS=2 \ | ||
| 140 | "$PROJECT_ROOT/ops/tx9-backup-prune" hermes >/dev/null | ||
| 141 | [[ "$(find "$prune_dir" -maxdepth 1 -name 'hermes-*.tar.gz.gpg' | wc -l)" == 2 ]] | ||
| 142 | |||
| 143 | missing_nas="$tmp/missing-nas" | ||
| 144 | TX9_BACKUP_DIR="$prune_dir" TX9_NAS_DIR="$missing_nas" TX9_BACKUP_RETAIN_COUNT=2 \ | ||
| 145 | "$PROJECT_ROOT/ops/tx9-backup-prune" hermes >/dev/null | ||
| 146 | [[ ! -e "$missing_nas" ]] | ||
| 147 | |||
| 148 | if "$PROJECT_ROOT/ops/tx9-backup-prune" '../escape' >/dev/null 2>&1; then | ||
| 149 | echo "tx9-backup-prune accepted an unsafe box name" >&2 | ||
| 150 | exit 1 | ||
| 151 | fi | ||
| 152 | if TX9_BACKUP_RETAIN_COUNT=not-a-number "$PROJECT_ROOT/ops/tx9-backup-prune" hermes >/dev/null 2>&1; then | ||
| 153 | echo "tx9-backup-prune accepted a non-numeric retain count" >&2 | ||
| 154 | exit 1 | ||
| 155 | fi | ||
| 156 | |||
| 157 | echo "tx9-host regression checks passed" | ||
| @@ -10,113 +10,47 @@ cd "$ROOT" · 9 unmodified lines | |||
| 10 | regression_files=(tests/regressions-*.sh) | 10 | regression_files=(tests/regressions-*.sh) |
| 11 | 11 | ||
| 12 | files=( | 12 | files=( |
| 13 | box | ||
| 14 | guest/hb | 13 | guest/hb |
| 15 | guest/hb-workload | 14 | guest/hb-workload |
| 16 | guest/lib-mcp.sh | 15 | guest/lib-mcp.sh |
| 17 | guest/profile.sh | 16 | guest/profile.sh |
| 18 | guest/agent-bash-profile.sh | 17 | guest/agent-bash-profile.sh |
| 19 | provision/provision.sh | 18 | provision/provision.sh |
| 20 | ops/tx9-host | 19 | docker/entrypoint.sh |
| 21 | ops/tx9-backup-prune | 20 | docker/executor-entrypoint.sh |
| 22 | tests/lib.sh | 21 | tests/lib.sh |
| 23 | tests/lifecycle-smoke.sh | ||
| 24 | tests/hermes-state.sh | 22 | tests/hermes-state.sh |
| 25 | tests/cli-surface.sh | ||
| 26 | "${regression_files[@]}" | 23 | "${regression_files[@]}" |
| 27 | tests/fixtures/smolvm | ||
| 28 | ) | 24 | ) |
| 29 | 25 | ||
| 30 | bash -n "${files[@]}" | 26 | bash -n "${files[@]}" |
| 31 | for file in box guest/hb guest/hb-workload guest/hermes-state provision/provision.sh ops/tx9-host ops/tx9-backup-prune tests/lifecycle-smoke.sh tests/hermes-state.sh tests/cli-surface.sh "${regression_files[@]}" tests/fixtures/smolvm; do | 27 | python3 -c 'compile(open("guest/hermes-state", encoding="utf-8").read(), "guest/hermes-state", "exec")' |
| 28 | for file in guest/hb guest/hb-workload guest/hermes-state provision/provision.sh tests/hermes-state.sh "${regression_files[@]}"; do | ||
| 32 | [[ -x "$file" ]] || { echo "not executable: $file" >&2; exit 1; } | 29 | [[ -x "$file" ]] || { echo "not executable: $file" >&2; exit 1; } |
| 33 | done | 30 | done |
| 34 | 31 | ||
| 32 | # --- guest/hb invariants ------------------------------------------------ | ||
| 35 | grep -q 'WIRE_EXECUTOR_MCP' guest/hb | 33 | grep -q 'WIRE_EXECUTOR_MCP' guest/hb |
| 36 | grep -q 'http://127.0.0.1:.*}/mcp' guest/hb | 34 | # HTTP MCP wiring goes through _executor_url (http://$EXECUTOR_HOST:$PORT/mcp, |
| 35 | # loopback by default, container-DNS name in the compose split). | ||
| 36 | grep -q "printf 'http://%s:%s/mcp' \"\$EXECUTOR_HOST\"" guest/hb | ||
| 37 | grep -q "printf 'http://%s:%s/mcp'.*EXECUTOR_PORT:-4788" guest/hb | ||
| 37 | if grep -q 'args = \["mcp"\]' guest/hb; then | 38 | if grep -q 'args = \["mcp"\]' guest/hb; then |
| 38 | echo "legacy stdio Executor MCP wiring remains" >&2 | 39 | echo "legacy stdio Executor MCP wiring remains" >&2 |
| 39 | exit 1 | 40 | exit 1 |
| 40 | fi | 41 | fi |
| 41 | grep -q 'trap _cleanup EXIT' box | ||
| 42 | grep -q 'Decrypting and validating archive before creating a VM' box | ||
| 43 | grep -q 'refusing to overwrite existing backup' box | ||
| 44 | grep -q 'guest_tmp="/root/hb-data-' box | ||
| 45 | grep -q 'guest_tmp="/root/hb-restore-' box | ||
| 46 | if grep -Eq 'guest_tmp="/tmp/hb-(data|restore)-' box; then | ||
| 47 | echo "smolvm-incompatible /tmp archive staging remains" >&2 | ||
| 48 | exit 1 | ||
| 49 | fi | ||
| 50 | if grep -q '\blsof\b' box; then | ||
| 51 | echo "lsof dependency remains" >&2 | ||
| 52 | exit 1 | ||
| 53 | fi | ||
| 54 | grep -q 'protocolVersion.*2025-03-26' guest/lib-mcp.sh | ||
| 55 | grep -q 'source .*guest/lib-mcp\.sh' box | ||
| 56 | grep -q 'lib-mcp\.sh' guest/hb | ||
| 57 | grep -q 'ca-certificates curl git jq tmux' provision/provision.sh | ||
| 58 | grep -q 'runuser -u agent.*hb.*write-manifest' provision/provision.sh | ||
| 59 | grep -q -- '--commit.*sha' provision/provision.sh | ||
| 60 | grep -q 'HERMES_INSTALLER_SHA256="[0-9a-f]\{64\}"' box.env | ||
| 61 | grep -q 'sha256sum --check --status' provision/provision.sh | ||
| 62 | python3 - <<'PY' | ||
| 63 | from pathlib import Path | ||
| 64 | |||
| 65 | source = Path("provision/provision.sh").read_text() | ||
| 66 | checksum = source.index('sha256sum --check --status') | ||
| 67 | destructive_checkout = source.index('rm -rf "$install_dir"') | ||
| 68 | if checksum >= destructive_checkout: | ||
| 69 | raise SystemExit("Hermes checkout is modified before installer verification") | ||
| 70 | installer = source.index('if bash "$installer"') | ||
| 71 | user_args = source.index('"${args[@]}"', installer) | ||
| 72 | pinned_dir = source.index('--dir "$install_dir"', user_args) | ||
| 73 | head_check = source.index('git -C "$install_dir" rev-parse HEAD', installer) | ||
| 74 | origin_reset = source.index('git -C "$install_dir" remote set-url origin', head_check) | ||
| 75 | if not installer < user_args < pinned_dir < head_check < origin_reset: | ||
| 76 | raise SystemExit("bundle origin is reset before installer and exact-HEAD verification") | ||
| 77 | |||
| 78 | box = Path("box").read_text() | ||
| 79 | stage_root = box.index('chown root:root "$stage" "$stage/home"') | ||
| 80 | stage_traverse = box.index('chmod 0711 "$stage" "$stage/home"', stage_root) | ||
| 81 | nested_owner = box.index('chown -R agent:agent "$stage/home/agent"', stage_traverse) | ||
| 82 | staged_verify = box.index('setpriv --reuid=agent', nested_owner) | ||
| 83 | if not stage_root < stage_traverse < nested_owner < staged_verify: | ||
| 84 | raise SystemExit("restore ownership boundary is not established before agent verification") | ||
| 85 | if 'chown agent:agent "$stage"' in box: | ||
| 86 | raise SystemExit("restore stage root is writable by the agent") | ||
| 87 | PY | ||
| 88 | if grep -q 'curl .*install.sh.*|.*bash' provision/provision.sh; then | ||
| 89 | echo "unverified Hermes installer execution remains" >&2 | ||
| 90 | exit 1 | ||
| 91 | fi | ||
| 92 | grep -q 'HERMES_INSTALL_ARGS cannot override protected installer option' provision/provision.sh | ||
| 93 | if grep -q 'git -C "$install_dir" fetch origin main' provision/provision.sh; then | ||
| 94 | echo "bundle provisioning still fetches the public origin" >&2 | ||
| 95 | exit 1 | ||
| 96 | fi | ||
| 97 | grep -q 'BOX_OVERLAY_GIB="64"' box.env | ||
| 98 | grep -q 'gateway-disable' box | ||
| 99 | grep -Fq -- '--exclude="*/.hermes/*.db-wal"' box | ||
| 100 | if grep -Fq -- '--exclude="*.db-wal"' box; then | ||
| 101 | echo "unrelated /data SQLite WAL files are still excluded" >&2 | ||
| 102 | exit 1 | ||
| 103 | fi | ||
| 104 | grep -q 'LoadCredential=backup-passphrase' ops/systemd/tx9-backup@.service | ||
| 105 | if grep -q '^Requires=tx9-box@' ops/systemd/tx9-*.service; then | ||
| 106 | echo "timer service still pulls the box service active" >&2 | ||
| 107 | exit 1 | ||
| 108 | fi | ||
| 109 | grep -q '^Type=simple$' ops/systemd/tx9-box@.service | ||
| 110 | grep -q 'tx9-host supervise %i' ops/systemd/tx9-box@.service | ||
| 111 | grep -Fq 'ExecStop=-/bin/kill -TERM $MAINPID' ops/systemd/tx9-box@.service | ||
| 112 | if grep -q '^RemainAfterExit=' ops/systemd/tx9-box@.service; then | ||
| 113 | echo "box supervisor is still modeled as a oneshot service" >&2 | ||
| 114 | exit 1 | ||
| 115 | fi | ||
| 116 | if grep -q 'chown -R' guest/hb; then | 42 | if grep -q 'chown -R' guest/hb; then |
| 117 | echo "reconcile still recursively changes durable-tree ownership" >&2 | 43 | echo "reconcile still recursively changes durable-tree ownership" >&2 |
| 118 | exit 1 | 44 | exit 1 |
| 119 | fi | 45 | fi |
| 46 | grep -q 'Executor target port closed while quiesced' guest/hb | ||
| 47 | grep -q 'gateway-is-disabled' guest/hb | ||
| 48 | grep -q 'LEGACY_QUIESCE_FILE' guest/hb guest/hb-workload | ||
| 49 | # The remote-executor override must outrank the on-disk token env, which a | ||
| 50 | # restore can carry stale from the source box. | ||
| 51 | grep -q 'BOXD_EXECUTOR_TOKEN' guest/hb | ||
| 52 | |||
| 53 | # --- guest/hb-workload invariants ---------------------------------------- | ||
| 120 | grep -q 'EXECUTOR_TARGET_PORT="${EXECUTOR_PORT:-4788}"' guest/hb-workload | 54 | grep -q 'EXECUTOR_TARGET_PORT="${EXECUTOR_PORT:-4788}"' guest/hb-workload |
| 121 | grep -q 'TCP:127.0.0.1:$EXECUTOR_TARGET_PORT' guest/hb-workload | 55 | grep -q 'TCP:127.0.0.1:$EXECUTOR_TARGET_PORT' guest/hb-workload |
| 122 | grep -q '_wait_api_ready' guest/hb-workload | 56 | grep -q '_wait_api_ready' guest/hb-workload |
| @@ -125,18 +59,18 @@ grep -q '\[ "$reconciled" = 1 \]' guest/hb-workload · 2 unmodified lines | |||
| 125 | grep -q '_refresh_targets' guest/hb-workload | 59 | grep -q '_refresh_targets' guest/hb-workload |
| 126 | grep -q '\. "$BOX_ENV"' guest/hb-workload | 60 | grep -q '\. "$BOX_ENV"' guest/hb-workload |
| 127 | grep -q '_stop_executor_bridge' guest/hb-workload | 61 | grep -q '_stop_executor_bridge' guest/hb-workload |
| 128 | grep -q 'LEGACY_QUIESCE_FILE' guest/hb guest/hb-workload | 62 | |
| 129 | grep -q 'Executor target port closed while quiesced' guest/hb | 63 | # --- provisioning / config invariants ------------------------------------ |
| 130 | grep -q 'gateway-is-disabled' guest/hb box | 64 | grep -q 'HERMES_INSTALLER_SHA256="[0-9a-f]\{64\}"' box.env |
| 131 | grep -q 'EXECUTOR_PORT:-4788.*mcp' guest/hb | 65 | grep -q 'tools) tools_only ;;' provision/provision.sh |
| 132 | grep -q 'guest_tmp="/data/home/agent/.config/hermes-box/import-' box | 66 | grep -q 'assets) assets_only ;;' provision/provision.sh |
| 133 | if grep -q 'guest_tmp="/root/hermes-import-' box; then | 67 | |
| 134 | echo "Hermes import remains unreadable to the agent" >&2 | 68 | # --- docker build assets -------------------------------------------------- |
| 135 | exit 1 | 69 | grep -q 'provision.sh tools' docker/Dockerfile |
| 136 | fi | 70 | grep -q 'provision.sh assets' docker/Dockerfile |
| 71 | grep -q 'EXECUTOR_MCP_TOKEN' docker/executor-entrypoint.sh | ||
| 72 | grep -q -- '--hostname 0.0.0.0' docker/executor-entrypoint.sh | ||
| 73 | |||
| 137 | grep -qi 'networking is always enabled' README.md | 74 | grep -qi 'networking is always enabled' README.md |
| 138 | grep -q 'check.yml. runs it on every push' README.md | ||
| 139 | [[ -f .depot/workflows/check.yml ]] | ||
| 140 | grep -q 'make check' .depot/workflows/check.yml | ||
| 141 | 75 | ||
| 142 | echo "static checks passed" | 76 | echo "static checks passed" |