feat: global install (#71) and tmux in templates (#65)

#71 — run as root, box installs once to /opt/box (world-readable) with a
/usr/local/bin shim, so every operator on a shared host has `box` on PATH
instead of `command not found`. Per-user ($HOME/.local) install is
byte-identical to before; BOX_HOME/BOX_BIN still override. A per-user tree
under /root is 0700 and unreadable to everyone else — the whole fleet got
`command not found` — so the root branch lands in a system location and
chmod -R a+rX's it, guarded on root. This is the path rig installs box through
(rig#24's `box` role).

#65 — `box tmux` runs `tmux new-session` inside the box, but the templates did
not install tmux, so it failed with `tmux: command not found`. tmux is now in
each template's cloud-init package list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-18 00:01:15 +00:00
parent d0a14a18a5
commit 9849021b6a
4 changed files with 71 additions and 2 deletions

View file

@ -5,8 +5,30 @@ which records not just what changed but what each drill run proved.
## Unreleased ## Unreleased
### Added
- **Global / root install** (#71) — run as root, box installs *once* to
`/opt/box` (world-readable) with the `box` symlink on `/usr/local/bin`, so
every operator on a shared host runs the same tree. Per-user installs are
unchanged (`$HOME/.local`); `BOX_HOME`/`BOX_BIN` still override. A per-user
tree under `/root` is `0700` and unreadable to everyone else — the whole fleet
got `command not found` — so the root branch lands in a system location and
`chmod -R a+rX`'s it (read for files, +search on dirs), guarded on root. This
unblocks "rig installs box" (rig#24's `box` role).
- **CI + a test suite**`.github/workflows/ci.yml` (a `check` job: globstar
`shellcheck -x` over `bin/* **/*.sh`, then `bash test/cli.sh`) and `test/cli.sh`,
dependency-free and runnable by a non-root user with no Incus. It exercises the
`install.sh` DEST/BINDIR branch functionally (both tiers + `BOX_HOME`/`BOX_BIN`
overrides), the CLI contract, and grep-guards the daemon-gated invariants and
tmux in every template — the box was the repo with "no tests and no CI".
### Fixed ### Fixed
- **`box tmux` works on every template** (#65) — `box tmux` runs
`tmux new-session` *inside* the box, but the templates did not install tmux, so
it failed with `tmux: command not found`. `tmux` is now in each template's
cloud-init package list (`blank`/`claude`/`codex`/`grok`).
- **`box setup-host` finishes in one run** (#63). When it had to add you to - **`box setup-host` finishes in one run** (#63). When it had to add you to
`incus-admin` it stopped there and told you to re-login and re-run — an `incus-admin` it stopped there and told you to re-login and re-run — an
`exit 0` that reported success having built none of the stack: no `boxnet`, `exit 0` that reported success having built none of the stack: no `boxnet`,

View file

@ -64,6 +64,25 @@ A version-aware upgrade that migrates boxes instead of asking you to is
(CI, images), `BOX_YES=1` answers every prompt yes and `BOX_SKIP_SETUP_HOST=1` (CI, images), `BOX_YES=1` answers every prompt yes and `BOX_SKIP_SETUP_HOST=1`
declines the host-setup step. declines the host-setup step.
### Global vs per-user install
Where box lands depends on **who runs the installer**, because on a shared host
box's tree is *executed by other users* — so it cannot hide in one user's home:
- **As root → global.** The tree goes to `/opt/box` (world-readable) and the
`box` symlink to `/usr/local/bin` (already on every login `PATH`). One
install, every operator on the host runs the same `box`. This is the fleet
path: [rig](https://github.com/heavy-duty/rig)'s `box` role
([rig#24](https://github.com/heavy-duty/rig/issues/24)) installs box once at
host bootstrap ([#71](https://github.com/heavy-duty/box/issues/71)).
- **As a normal user → per-user.** The tree goes to `~/.local/share/box` and
the symlink to `~/.local/bin` — the solo path, unchanged. Nobody else needs
to run your box.
`BOX_HOME` / `BOX_BIN` override the destination on either path. A per-user
install under `/root` would be `0700` and unreadable to everyone else — which
is exactly the bug the root branch fixes.
## One-time host setup (Ubuntu 24.04 / Debian 13) ## One-time host setup (Ubuntu 24.04 / Debian 13)
The installer already does this. Run it directly to set up a host you The installer already does this. Run it directly to set up a host you

View file

@ -10,8 +10,20 @@ set -euo pipefail
REPO="${BOX_REPO:-heavy-duty/box}" REPO="${BOX_REPO:-heavy-duty/box}"
REF="${BOX_REF:-main}" REF="${BOX_REF:-main}"
DEST="${BOX_HOME:-$HOME/.local/share/box}" # Root installs GLOBALLY, non-root installs per-user. box's install tree is
BINDIR="${BOX_BIN:-$HOME/.local/bin}" # EXECUTED by other users (the multi-user host path: rig installs box once, every
# incus-group operator runs it) — unlike rig, which is root-only and can hide in
# /root. So a root install must land in a system location, not $HOME: /root is
# 0700, so a $HOME/.local tree there is unreadable to everyone else and the whole
# fleet gets 'command not found' (#71). /opt/box is the world-readable system
# tree; /usr/local/bin is already on every login PATH. BOX_HOME/BOX_BIN still win.
if [ "$(id -u)" -eq 0 ]; then
DEST="${BOX_HOME:-/opt/box}"
BINDIR="${BOX_BIN:-/usr/local/bin}"
else
DEST="${BOX_HOME:-$HOME/.local/share/box}"
BINDIR="${BOX_BIN:-$HOME/.local/bin}"
fi
log() { printf 'box-install: %s\n' "$*"; } log() { printf 'box-install: %s\n' "$*"; }
warn() { printf 'box-install: WARNING: %s\n' "$*" >&2; } warn() { printf 'box-install: WARNING: %s\n' "$*" >&2; }
@ -96,6 +108,16 @@ mv "$EXTRACTED" "$DEST"
chmod +x "$DEST/bin/box" chmod +x "$DEST/bin/box"
# A global (root) install is run by OTHER users, but mv preserves the tarball's
# root:root ownership and GitHub's archives carry no world bits on some paths — so
# without this, a non-root caller cannot even traverse into $DEST to reach bin/box.
# Root owns the tree, nobody else writes it, everybody reads it. a+rX: read on
# files, +search (x) on directories only. Guarded on root so the per-user install
# stays byte-identical to before.
if [ "$(id -u)" -eq 0 ]; then
chmod -R a+rX "$DEST"
fi
# --- put box on PATH ------------------------------------------------------- # --- put box on PATH -------------------------------------------------------
mkdir -p "$BINDIR" mkdir -p "$BINDIR"
ln -sf "$DEST/bin/box" "$BINDIR/box" ln -sf "$DEST/bin/box" "$BINDIR/box"

View file

@ -7,3 +7,9 @@ users:
sudo: "ALL=(ALL) NOPASSWD:ALL" sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true lock_passwd: true
package_update: false package_update: false
# The one package a blank box still needs: 'box tmux' runs 'tmux new-session'
# INSIDE the box, and a bare Debian image ships no tmux — without this it fails
# with "tmux: command not found" (#65). cloud-init refreshes the apt lists on its
# own whenever 'packages' is non-empty, so package_update stays false here.
packages:
- tmux