From a38bbfbae2f4e4db8aa0485dde06af599d8d3790 Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 26 Jul 2026 09:24:44 +0000 Subject: [PATCH] build: pin and verify image inputs --- Dockerfile.gateway | 14 ++++-- Dockerfile.orchestrator | 6 ++- Dockerfile.orchestrator.fc | 7 ++- MANIFEST.in | 2 + bot_bottle/backend/docker/util.py | 24 ++++++++- .../backend/firecracker/infra_artifact.py | 6 +-- bot_bottle/backend/firecracker/infra_vm.py | 7 ++- bot_bottle/contrib/claude/Dockerfile | 17 ++++--- bot_bottle/contrib/codex/Dockerfile | 20 +++++--- bot_bottle/contrib/pi/Dockerfile | 20 ++++---- bot_bottle/contrib/pi/package.json | 3 ++ bot_bottle/resources.py | 1 + pyproject.toml | 5 ++ setup.py | 1 + tests/unit/test_docker_util_image.py | 49 +++++++++++++++++++ tests/unit/test_firecracker_infra_vm.py | 12 ++++- tests/unit/test_infra_artifact.py | 16 ++++++ 17 files changed, 172 insertions(+), 38 deletions(-) diff --git a/Dockerfile.gateway b/Dockerfile.gateway index 4ae12511..dc1317eb 100644 --- a/Dockerfile.gateway +++ b/Dockerfile.gateway @@ -36,11 +36,12 @@ # 9420 git-gate smart HTTP (VM-backend agent-facing transport) # 9100 supervise (MCP HTTP) -# Based on `python:3.12-slim` (Debian trixie) rather than the +# Based on an exact `python:3.12.13-slim-trixie` multi-architecture manifest +# rather than the # `mitmproxy/mitmproxy` image (Debian bookworm), matching the trixie base the # orchestrator image needs for buildah (Dockerfile.orchestrator.fc). mitmproxy # is pip-installed to the same effect as the upstream image. -FROM python:3.12-slim +FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de # Runtime system deps: # git supplies the `git daemon` subcommand (no separate package) @@ -54,10 +55,13 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* # mitmdump (the egress data plane). The upstream mitmproxy image baked -# this in; on the plain python base we pip-install the same pinned -# version. Its CA dir is set explicitly via `--set confdir=` in +# this in; on the plain python base we install a fully resolved lock whose +# distributions are all hash-verified. Its CA dir is set explicitly via +# `--set confdir=` in # egress-entrypoint.sh, so it doesn't depend on a `mitmproxy` home user. -RUN pip install --no-cache-dir mitmproxy==11.1.3 +COPY requirements.gateway.lock /tmp/requirements.gateway.lock +RUN pip install --no-cache-dir --require-hashes \ + -r /tmp/requirements.gateway.lock # gitleaks (the pre-receive hook's secret scanner). Installed from its # official release, pinned by version + SHA256 and verified — rather than diff --git a/Dockerfile.orchestrator b/Dockerfile.orchestrator index 90082875..d01ee9bf 100644 --- a/Dockerfile.orchestrator +++ b/Dockerfile.orchestrator @@ -16,9 +16,11 @@ # secret-dense control plane on a minimal dependency surface is the point # (PRD 0070's "secret concentration"). # -# Shares the trixie `python:3.12-slim` base with the gateway image. +# Shares an exact multi-architecture Python/trixie manifest with the gateway +# image. The version-qualified tag keeps the human-readable upstream version; +# the digest makes the bytes immutable. -FROM python:3.12-slim +FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de WORKDIR /app diff --git a/Dockerfile.orchestrator.fc b/Dockerfile.orchestrator.fc index 2fa69acc..8221e560 100644 --- a/Dockerfile.orchestrator.fc +++ b/Dockerfile.orchestrator.fc @@ -12,8 +12,11 @@ # bare microVM (no fuse-overlayfs / overlay module / subuid maps). The trixie # base (from Dockerfile.orchestrator's python:3.12-slim) carries buildah 1.39, # which parses the Dockerfile heredocs agent images use (bookworm's 1.28 can't). -# Matches image_builder. -FROM bot-bottle-orchestrator:latest +# Matches image_builder. There is deliberately no default: the build coordinator +# passes the exact local image ID returned by `docker image inspect`, so this +# stage cannot silently resolve a stale `:latest` tag. +ARG ORCHESTRATOR_BASE_IMAGE +FROM ${ORCHESTRATOR_BASE_IMAGE} RUN apt-get update \ && apt-get install -y --no-install-recommends \ diff --git a/MANIFEST.in b/MANIFEST.in index a39c977c..92dafc83 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,5 +4,7 @@ include Dockerfile.gateway include Dockerfile.orchestrator include Dockerfile.orchestrator.fc +include requirements.gateway.in +include requirements.gateway.lock include nix/firecracker-netpool.nix include scripts/firecracker-netpool.sh diff --git a/bot_bottle/backend/docker/util.py b/bot_bottle/backend/docker/util.py index b5e20430..2a9c24fd 100644 --- a/bot_bottle/backend/docker/util.py +++ b/bot_bottle/backend/docker/util.py @@ -119,7 +119,13 @@ def docker_cp(src: str, dest: str) -> None: f"{(result.stderr or '').strip() or ''}") -def build_image(ref: str, context: str, *, dockerfile: str = "") -> None: +def build_image( + ref: str, + context: str, + *, + dockerfile: str = "", + build_args: dict[str, str] | None = None, +) -> None: """Invokes `docker build` every call. Layer cache makes no-change rebuilds cheap; running every time means Dockerfile edits land without manual `docker rmi`. @@ -139,10 +145,26 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None: args.append("--no-cache") if dockerfile: args.extend(["-f", dockerfile]) + for name, value in (build_args or {}).items(): + args.extend(["--build-arg", f"{name}={value}"]) args.append(context) subprocess.run(args, check=True) +def image_id(ref: str) -> str: + """Return the exact content-addressed ID for a local image. + + This is used when one locally built image is another Dockerfile's base: + passing the ID prevents a mutable tag from being resolved between builds. + """ + result = run_docker(["docker", "image", "inspect", "--format", "{{.Id}}", ref]) + image = result.stdout.strip() + if result.returncode != 0 or not image.startswith("sha256:"): + detail = (result.stderr or result.stdout or "").strip() + die(f"could not resolve exact image ID for {ref!r}: {detail or ''}") + return image + + def verify_agent_image(image: str, argv: tuple[str, ...]) -> None: """Run `argv` inside a throwaway container of a freshly built agent image and die loudly if it fails, instead of shipping an image diff --git a/bot_bottle/backend/firecracker/infra_artifact.py b/bot_bottle/backend/firecracker/infra_artifact.py index ba0ca075..3ef23d1d 100644 --- a/bot_bottle/backend/firecracker/infra_artifact.py +++ b/bot_bottle/backend/firecracker/infra_artifact.py @@ -49,9 +49,9 @@ _ARTIFACT_FORMAT = "1" # from its own generic package; the Dockerfiles baked into each differ (only the # orchestrator rootfs carries buildah), so the versions are hashed separately. ROLES = ("orchestrator", "gateway") -_DOCKERFILES = { +_BUILD_INPUTS = { "orchestrator": ("Dockerfile.orchestrator", "Dockerfile.orchestrator.fc"), - "gateway": ("Dockerfile.gateway",), + "gateway": ("Dockerfile.gateway", "requirements.gateway.lock"), } _DEFAULT_BASE = "https://gitea.dideric.is" @@ -101,7 +101,7 @@ def infra_artifact_version( h.update(str(path.relative_to(repo_root)).encode()) h.update(b"\0") h.update(path.read_bytes()) - for name in _DOCKERFILES[role]: + for name in _BUILD_INPUTS[role]: h.update(name.encode()) h.update(b"\0") h.update((repo_root / name).read_bytes()) diff --git a/bot_bottle/backend/firecracker/infra_vm.py b/bot_bottle/backend/firecracker/infra_vm.py index 0fa03f0f..9f2a1a48 100644 --- a/bot_bottle/backend/firecracker/infra_vm.py +++ b/bot_bottle/backend/firecracker/infra_vm.py @@ -133,10 +133,15 @@ def build_infra_images_with_docker() -> None: root = str(resources.build_root()) docker_mod.build_image( _ORCHESTRATOR_IMAGE, root, dockerfile="Dockerfile.orchestrator") + orchestrator_id = docker_mod.image_id(_ORCHESTRATOR_IMAGE) docker_mod.build_image( _GATEWAY_IMAGE, root, dockerfile="Dockerfile.gateway") docker_mod.build_image( - _ORCHESTRATOR_FC_IMAGE, root, dockerfile="Dockerfile.orchestrator.fc") + _ORCHESTRATOR_FC_IMAGE, + root, + dockerfile="Dockerfile.orchestrator.fc", + build_args={"ORCHESTRATOR_BASE_IMAGE": orchestrator_id}, + ) def build_rootfs_dir(role: str) -> Path: diff --git a/bot_bottle/contrib/claude/Dockerfile b/bot_bottle/contrib/claude/Dockerfile index 78d05573..e5e04621 100644 --- a/bot_bottle/contrib/claude/Dockerfile +++ b/bot_bottle/contrib/claude/Dockerfile @@ -8,9 +8,8 @@ # Layer ordering is deliberate: the npm install lives in its own layer so # changes to the rest of the repo (or to the CMD) don't bust it. -# Current Node LTS; slim variant keeps the image small while still -# providing apt-get for any future additions. -FROM node:22-trixie-slim +# Version-qualified Node LTS, pinned to its multi-architecture manifest. +FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba # Install runtime system deps. claude-code shells out to git for several # features (status checks, commits, PR creation) — without git in the @@ -39,11 +38,13 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends python3 python3-pip python3-venv \ && rm -rf /var/lib/apt/lists/* -# Install claude-code globally. Pinned to the version verified in the v1 -# build (`claude --version` returns 2.1.126). Bump deliberately when -# rolling forward; an unpinned install would mean rebuilds silently pick -# up new behavior. -RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.172 \ +# Install from the committed npm lock. `npm ci` verifies every registry +# artifact against its lockfile integrity and refuses dependency drift. +COPY bot_bottle/contrib/claude/package.json \ + bot_bottle/contrib/claude/package-lock.json /opt/claude/ +RUN cd /opt/claude \ + && npm ci --omit=dev --no-fund --no-audit \ + && ln -s /opt/claude/node_modules/.bin/claude /usr/local/bin/claude \ && npm cache clean --force # Git reads both ~/.gitconfig and ~/.config/git/config. Keep its XDG config diff --git a/bot_bottle/contrib/codex/Dockerfile b/bot_bottle/contrib/codex/Dockerfile index 95160427..b876dfbe 100644 --- a/bot_bottle/contrib/codex/Dockerfile +++ b/bot_bottle/contrib/codex/Dockerfile @@ -3,7 +3,7 @@ # Mirrors the default Claude image shape: Node LTS, git/network tooling, # non-root node user, and the provider CLI installed for that user. -FROM node:22-trixie-slim +FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba # The standalone installer is used below because remote-control requires its # managed package layout. Keep this exact release in sync with the verified @@ -35,10 +35,18 @@ WORKDIR /home/node ENV PATH="/home/node/.local/bin:${PATH}" -# Remote-control support requires the standalone Codex install layout -# under ~/.codex/packages/standalone/current. The npm package can run -# the TUI, but remote-control commands expect this installer-owned path. -RUN mkdir -p /home/node/.codex \ - && curl -fsSL https://chatgpt.com/codex/install.sh | sh +# Remote-control support requires the standalone Codex install layout under +# ~/.codex/packages/standalone/current. Fetch the installer from the same +# immutable release tag as the requested CLI, verify the committed checksum +# before executing it, and let the official installer verify the selected +# release archive against upstream release metadata. +COPY --chown=node:node bot_bottle/contrib/codex/install.sh.sha256 /tmp/install.sh.sha256 +RUN curl -fsSL \ + "https://raw.githubusercontent.com/openai/codex/rust-v${CODEX_VERSION}/scripts/install/install.sh" \ + -o /tmp/install.sh \ + && cd /tmp \ + && sha256sum -c install.sh.sha256 \ + && CODEX_NON_INTERACTIVE=1 sh /tmp/install.sh --release "${CODEX_VERSION}" \ + && test "$(codex --version)" = "codex-cli ${CODEX_VERSION}" CMD ["codex"] diff --git a/bot_bottle/contrib/pi/Dockerfile b/bot_bottle/contrib/pi/Dockerfile index 8c5aa7ff..bb2fd92c 100644 --- a/bot_bottle/contrib/pi/Dockerfile +++ b/bot_bottle/contrib/pi/Dockerfile @@ -2,7 +2,7 @@ # # Node LTS, git/network tooling, and the Pi coding-agent CLI installed globally. -FROM node:22-trixie-slim +FROM node:22.23.1-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -19,9 +19,6 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends python3 python3-pip python3-venv \ && rm -rf /var/lib/apt/lists/* -RUN npm install -g --ignore-scripts --no-fund --no-audit @earendil-works/pi-coding-agent \ - && npm cache clean --force - RUN install -d -o node -g node -m 755 /home/node/.config /home/node/.config/git \ && mkdir -p /home/node/.pi/agent \ /home/node/.pi/context-mode/sessions \ @@ -34,10 +31,15 @@ RUN install -d -o node -g node -m 755 /home/node/.config /home/node/.config/git USER node WORKDIR /home/node -RUN pi install npm:@harms-haus/pi-cwd \ - && pi install npm:pi-web-access \ - && pi install npm:context-mode \ - && pi install npm:pi-subagents \ - && pi install npm:pi-mcp-adapter +# Pi discovers npm packages from its agent directory. Installing the CLI and +# extensions together from the committed lock makes all direct and transitive +# package versions deterministic, with npm integrity verification. +COPY --chown=node:node bot_bottle/contrib/pi/package.json \ + bot_bottle/contrib/pi/package-lock.json /home/node/.pi/agent/ +RUN cd /home/node/.pi/agent \ + && npm ci --omit=dev --no-fund --no-audit \ + && npm cache clean --force + +ENV PATH="/home/node/.pi/agent/node_modules/.bin:${PATH}" CMD ["pi"] diff --git a/bot_bottle/contrib/pi/package.json b/bot_bottle/contrib/pi/package.json index d0c4d375..cf285c74 100644 --- a/bot_bottle/contrib/pi/package.json +++ b/bot_bottle/contrib/pi/package.json @@ -3,6 +3,9 @@ "private": true, "dependencies": { "@earendil-works/pi-coding-agent": "0.81.1", + "@earendil-works/pi-agent-core": "0.81.1", + "@earendil-works/pi-ai": "0.81.1", + "@earendil-works/pi-tui": "0.81.1", "@harms-haus/pi-cwd": "1.0.0", "context-mode": "1.0.169", "pi-mcp-adapter": "2.11.0", diff --git a/bot_bottle/resources.py b/bot_bottle/resources.py index 6fa9c671..7d88ee3a 100644 --- a/bot_bottle/resources.py +++ b/bot_bottle/resources.py @@ -40,6 +40,7 @@ BUNDLED_RESOURCES: tuple[str, ...] = ( "Dockerfile.gateway", "Dockerfile.orchestrator", "Dockerfile.orchestrator.fc", + "requirements.gateway.lock", "nix/firecracker-netpool.nix", "scripts/firecracker-netpool.sh", ) diff --git a/pyproject.toml b/pyproject.toml index 615afb68..d7cb0e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,13 @@ include = ["bot_bottle*"] bot_bottle = [ "gateway/egress/entrypoint.sh", "contrib/claude/Dockerfile", + "contrib/claude/package.json", + "contrib/claude/package-lock.json", "contrib/codex/Dockerfile", + "contrib/codex/install.sh.sha256", "contrib/pi/Dockerfile", + "contrib/pi/package.json", + "contrib/pi/package-lock.json", "backend/firecracker/netpool.defaults.env", "backend/macos_container/nested-containers-init.sh", ] diff --git a/setup.py b/setup.py index 331c9e49..f61268ef 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,7 @@ _BUNDLED_RESOURCES = ( "Dockerfile.gateway", "Dockerfile.orchestrator", "Dockerfile.orchestrator.fc", + "requirements.gateway.lock", "nix/firecracker-netpool.nix", "scripts/firecracker-netpool.sh", ) diff --git a/tests/unit/test_docker_util_image.py b/tests/unit/test_docker_util_image.py index a74196d5..4040d6a1 100644 --- a/tests/unit/test_docker_util_image.py +++ b/tests/unit/test_docker_util_image.py @@ -121,5 +121,54 @@ class TestCommitContainer(unittest.TestCase): self.assertIn("my-tag:v1", die.call_args.args[0]) +class TestBuildImage(unittest.TestCase): + def test_passes_build_args_without_mutating_the_value(self): + with patch.object( + docker_mod.subprocess, "run", return_value=_ok(), + ) as run, patch.object(docker_mod, "info"): + docker_mod.build_image( + "derived:test", + "/context", + dockerfile="Dockerfile.derived", + build_args={"BASE_IMAGE": "sha256:" + "a" * 64}, + ) + self.assertEqual( + [ + "docker", "build", "-t", "derived:test", + "-f", "Dockerfile.derived", + "--build-arg", "BASE_IMAGE=sha256:" + "a" * 64, + "/context", + ], + run.call_args.args[0], + ) + + +class TestImageId(unittest.TestCase): + def test_returns_content_addressed_local_id(self): + expected = "sha256:" + "b" * 64 + with patch.object( + docker_mod, "run_docker", return_value=_ok(stdout=expected + "\n"), + ) as run: + self.assertEqual(expected, docker_mod.image_id("base:mutable")) + self.assertEqual( + [ + "docker", "image", "inspect", "--format", "{{.Id}}", + "base:mutable", + ], + run.call_args.args[0], + ) + + def test_rejects_failed_or_non_content_addressed_inspect(self): + for result in (_fail("missing"), _ok(stdout="base:latest\n")): + with self.subTest(result=result), patch.object( + docker_mod, "run_docker", return_value=result, + ), patch.object( + docker_mod, "die", side_effect=SystemExit("die"), + ) as die: + with self.assertRaises(SystemExit): + docker_mod.image_id("base:latest") + die.assert_called_once() + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_firecracker_infra_vm.py b/tests/unit/test_firecracker_infra_vm.py index 52fe6b97..f5b346aa 100644 --- a/tests/unit/test_firecracker_infra_vm.py +++ b/tests/unit/test_firecracker_infra_vm.py @@ -107,7 +107,12 @@ class TestEnsureBuilt(unittest.TestCase): def test_local_mode_builds_orchestrator_before_its_fc_image(self): with patch.dict(os.environ, {"BOT_BOTTLE_INFRA_BUILD": "local"}), \ - patch.object(infra_vm.docker_mod, "build_image") as build: + patch.object(infra_vm.docker_mod, "build_image") as build, \ + patch.object( + infra_vm.docker_mod, + "image_id", + return_value="sha256:" + "a" * 64, + ) as image_id: infra_vm.ensure_built() tags = [c.args[0] for c in build.call_args_list] # orchestrator-fc is FROM orchestrator, so the base is built first. @@ -116,6 +121,11 @@ class TestEnsureBuilt(unittest.TestCase): self.assertEqual(infra_vm._ORCHESTRATOR_FC_IMAGE, tags[-1]) self.assertLess(tags.index(infra_vm._ORCHESTRATOR_IMAGE), tags.index(infra_vm._ORCHESTRATOR_FC_IMAGE)) + image_id.assert_called_once_with(infra_vm._ORCHESTRATOR_IMAGE) + self.assertEqual( + {"ORCHESTRATOR_BASE_IMAGE": "sha256:" + "a" * 64}, + build.call_args_list[-1].kwargs["build_args"], + ) class TestStop(unittest.TestCase): diff --git a/tests/unit/test_infra_artifact.py b/tests/unit/test_infra_artifact.py index d9bf0aa6..3affe72b 100644 --- a/tests/unit/test_infra_artifact.py +++ b/tests/unit/test_infra_artifact.py @@ -110,6 +110,7 @@ class TestVersionInputs(unittest.TestCase): for name in ("Dockerfile.orchestrator", "Dockerfile.orchestrator.fc", "Dockerfile.gateway"): (root / name).write_text(f"FROM scratch # {name}\n") + (root / "requirements.gateway.lock").write_text("mitmproxy==11.1.3\n") (root / "pyproject.toml").write_text("[project]\nname = 'bot-bottle'\n") def test_pyproject_toml_change_bumps_version(self) -> None: @@ -146,6 +147,21 @@ class TestVersionInputs(unittest.TestCase): after = ia.infra_artifact_version("init", _ROLE, repo_root=root) self.assertNotEqual(before, after) + def test_gateway_lock_change_bumps_gateway_version(self) -> None: + with tempfile.TemporaryDirectory() as d: + root = Path(d) + self._fake_repo(root) + before = ia.infra_artifact_version( + "init", "gateway", repo_root=root, + ) + (root / "requirements.gateway.lock").write_text( + "mitmproxy==11.1.3 --hash=sha256:changed\n", + ) + after = ia.infra_artifact_version( + "init", "gateway", repo_root=root, + ) + self.assertNotEqual(before, after) + def test_pyc_and_pycache_ignored(self) -> None: with tempfile.TemporaryDirectory() as d: root = Path(d)