From 1b86847f0df6651d7c4bd469eaad52f7384f8779 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 27 Jul 2026 17:10:19 +0000 Subject: [PATCH] feat(installer): select immutable release bundles --- README.md | 13 +++- install.sh | 115 ++++++++++++++++++++++++++++-- tests/unit/test_install_script.py | 15 ++++ 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 899ed7f1..4e0b735e 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,15 @@ When the agent exits, `cli.py` tears down every gateway and both networks; nothi curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh ``` -The installer is a bootstrapper: it finds a suitable Python, installs bot-bottle with `pipx` (falling back to `pip --user`), creates `~/.bot-bottle`, and runs `bot-bottle doctor`. It is idempotent and never uses `sudo`. Python-native users can skip it entirely with `pipx install bot-bottle` or `uv tool install bot-bottle`. +The installer is a bootstrapper: it finds a suitable Python, resolves the +latest qualified production bundle, verifies its wheel checksum, installs with +`pipx` (falling back to a private venv), creates `~/.bot-bottle`, and runs +`bot-bottle doctor`. It is idempotent and never uses `sudo`. + +Select staging with `BOT_BOTTLE_CHANNEL=staging`, an exact qualified release +with `BOT_BOTTLE_VERSION=vX.Y.Z[-rc.N]`, or an exact published commit with +`BOT_BOTTLE_REF=<40-character-sha>`. Commit installs print an explicit warning +because snapshots may not have passed release qualification. ### Requirements @@ -83,7 +91,8 @@ The installer is a bootstrapper: it finds a suitable Python, installs bot-bottle **No `pipx` required.** If `pipx` is present the installer uses it and stays out of the way. If it isn't, bot-bottle installs into a private venv at `~/.bot-bottle/venv` (override with `BOT_BOTTLE_VENV`) and symlinks the entry point into `~/.local/bin`. There is deliberately no `pip install --user` path: Homebrew, python.org and Debian/Ubuntu interpreters are all externally managed (PEP 668), which blocks `--user` outright — so on a Mac it is never the fallback it appears to be. A venv is exempt from PEP 668, and `venv` is stdlib, so unlike `pipx` there is nothing to bootstrap first. -**`git`**, because the default install spec is a `git+` URL. Set `BOT_BOTTLE_INSTALL_SPEC` to a wheel path or index name to avoid it. +**`git`** is needed only when `BOT_BOTTLE_INSTALL_SPEC` explicitly selects a +`git+` URL. The normal published-wheel path does not require it. **A backend**, which the installer deliberately does *not* install for you — `doctor` reports what's missing afterwards. On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the gateway plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. diff --git a/install.sh b/install.sh index 0910f2fb..16990493 100755 --- a/install.sh +++ b/install.sh @@ -17,10 +17,13 @@ # Env: # BOT_BOTTLE_PYTHON interpreter to install with (skips the search) # BOT_BOTTLE_INSTALL_SPEC pip/git spec to install instead of the default +# BOT_BOTTLE_CHANNEL production (default) or staging +# BOT_BOTTLE_VERSION exact qualified vX.Y.Z[-rc.N] release +# BOT_BOTTLE_REF exact 40-character commit snapshot (warns untested) # BOT_BOTTLE_VENV where the non-pipx install lives (~/.bot-bottle/venv) set -eu -PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-git+https://gitea.dideric.is/didericis/bot-bottle.git}" +PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-}" VENV="${BOT_BOTTLE_VENV:-${HOME}/.bot-bottle/venv}" MIN_PYTHON_MAJOR=3 MIN_PYTHON_MINOR=11 @@ -126,9 +129,113 @@ if [ "${PYTHON}" != "${path_python}" ]; then fi fi -# Installing a `git+` spec (the default) shells out to git under the hood, -# whether via pipx or pip. Fail early with a clear message rather than deep -# inside the installer's output. +# --- resolve an immutable published wheel ----------------------------------- + +if [ -z "${PACKAGE_SPEC}" ]; then + selectors=0 + [ -n "${BOT_BOTTLE_REF:-}" ] && selectors=$((selectors + 1)) + [ -n "${BOT_BOTTLE_VERSION:-}" ] && selectors=$((selectors + 1)) + [ -n "${BOT_BOTTLE_CHANNEL:-}" ] && selectors=$((selectors + 1)) + [ "${selectors}" -le 1 ] || die \ + "BOT_BOTTLE_REF, BOT_BOTTLE_VERSION, and BOT_BOTTLE_CHANNEL are mutually exclusive." + + downloads="${HOME}/.bot-bottle/downloads" + mkdir -p "${downloads}" + PACKAGE_SPEC="$("${PYTHON}" - \ + "${BOT_BOTTLE_REF:-}" \ + "${BOT_BOTTLE_VERSION:-}" \ + "${BOT_BOTTLE_CHANNEL:-production}" \ + "${downloads}" <<'PY' +import hashlib +import json +import re +import sys +import urllib.request +from pathlib import Path + +ref, version, channel, download_root = sys.argv[1:] +base = "https://gitea.dideric.is/api/packages/didericis/generic" + +def fetch_json(url): + with urllib.request.urlopen(url, timeout=30) as response: + return json.load(response) + +if ref: + if re.fullmatch(r"[0-9a-f]{40}", ref) is None: + raise SystemExit("bot-bottle install: error: BOT_BOTTLE_REF must be 40 lowercase hex") + index_url = f"{base}/bot-bottle-builds/{ref}/bundle-index.json" + print( + "bot-bottle install: WARNING: installing an exact commit snapshot; " + "it may not have passed release qualification.", + file=sys.stderr, + ) + selector = f"commit {ref}" +else: + if version: + if re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-rc\.[0-9]+)?", version) is None: + raise SystemExit("bot-bottle install: error: invalid BOT_BOTTLE_VERSION") + pointer_url = f"{base}/bot-bottle-releases/{version}/release.json" + selector = f"release {version}" + else: + if channel not in ("production", "staging"): + raise SystemExit( + "bot-bottle install: error: BOT_BOTTLE_CHANNEL must be production or staging") + pointer_url = f"{base}/bot-bottle-channels/{channel}/channel.json" + selector = f"{channel} channel" + pointer = fetch_json(pointer_url) + if pointer.get("schema") != 1 or pointer.get("qualified") is not True: + raise SystemExit("bot-bottle install: error: release pointer is not qualified") + index_url = pointer.get("bundle_index_url", "") + if not isinstance(index_url, str) or not index_url.startswith("https://"): + raise SystemExit("bot-bottle install: error: release pointer has no immutable bundle") + +index = fetch_json(index_url) +source_commit = index.get("source_commit", "") +if re.fullmatch(r"[0-9a-f]{40}", source_commit) is None: + raise SystemExit("bot-bottle install: error: bundle has an invalid source commit") +if ref and source_commit != ref: + raise SystemExit("bot-bottle install: error: bundle does not match BOT_BOTTLE_REF") +wheel = index.get("wheel") +if not isinstance(wheel, dict): + raise SystemExit("bot-bottle install: error: bundle has no wheel") +filename, url, expected = ( + wheel.get("filename"), wheel.get("url"), wheel.get("sha256")) +if ( + not isinstance(filename, str) + or re.fullmatch(r"[A-Za-z0-9_.-]+\.whl", filename) is None + or not isinstance(url, str) + or not url.startswith("https://") + or not url.endswith("/" + filename) + or not isinstance(expected, str) + or re.fullmatch(r"[0-9a-f]{64}", expected) is None +): + raise SystemExit("bot-bottle install: error: bundle wheel metadata is invalid") + +destination = Path(download_root) / source_commit / filename +destination.parent.mkdir(parents=True, exist_ok=True) +temporary = destination.with_suffix(destination.suffix + ".part") +digest = hashlib.sha256() +with urllib.request.urlopen(url, timeout=60) as response, temporary.open("wb") as output: + while chunk := response.read(1 << 20): + digest.update(chunk) + output.write(chunk) +if digest.hexdigest() != expected: + temporary.unlink(missing_ok=True) + raise SystemExit("bot-bottle install: error: downloaded wheel checksum mismatch") +temporary.replace(destination) +print( + f"bot-bottle install: selected {selector} " + f"(source {source_commit}, sha256 {expected[:16]}…)", + file=sys.stderr, +) +print(destination) +PY + )" +fi + +# An explicit `git+` override shells out to git under the hood, whether via +# pipx or pip. Fail early with a clear message rather than deep inside the +# installer's output. case "${PACKAGE_SPEC}" in git+*|*.git) command -v git >/dev/null 2>&1 || die \ diff --git a/tests/unit/test_install_script.py b/tests/unit/test_install_script.py index ce999d83..2caf4d6b 100644 --- a/tests/unit/test_install_script.py +++ b/tests/unit/test_install_script.py @@ -95,6 +95,21 @@ class TestInstallScript(unittest.TestCase): # Tests / local installs point BOT_BOTTLE_INSTALL_SPEC at a checkout. self.assertIn("BOT_BOTTLE_INSTALL_SPEC", self.text) + def test_published_install_selectors_are_mutually_exclusive(self): + self.assertIn("BOT_BOTTLE_CHANNEL", self.text) + self.assertIn("BOT_BOTTLE_VERSION", self.text) + self.assertIn("BOT_BOTTLE_REF", self.text) + self.assertIn("are mutually exclusive", self.text) + + def test_commit_snapshot_warns_and_wheel_is_checksum_verified(self): + self.assertIn("may not have passed release qualification", self.text) + self.assertIn("downloaded wheel checksum mismatch", self.text) + self.assertIn("hashlib.sha256()", self.text) + + def test_default_install_uses_production_channel(self): + self.assertIn('${BOT_BOTTLE_CHANNEL:-production}', self.text) + self.assertIn("bot-bottle-channels", self.text) + def test_requires_git_for_git_specs(self): # A git+ / .git spec (the default) shells out to git; the script must # gate on it rather than failing opaquely inside pipx/pip.