diff --git a/images/Dockerfile b/images/Dockerfile index 55e1459..d0dcf83 100644 --- a/images/Dockerfile +++ b/images/Dockerfile @@ -4,10 +4,24 @@ # @wirenboard/agent-vm on npm). # # Contents: Debian 13 slim + the three AI coding agents (Claude -# Code, OpenCode, Codex), minimum dev tooling, and the docker engine -# (docker.io + containerd + runc + fuse-overlayfs). Other heavy +# Code, OpenCode, Codex), Docker engine, network/diagnostic CLIs, +# codestyle-pinned Python linters (black/isort/pylint matching +# `wirenboard/codestyle`), and a clone of `wirenboard/codestyle` +# at /opt/wirenboard/codestyle. Two heavier blocks (WB +# build-essentials family + armhf/arm64 cross toolchains) are +# present in the Dockerfile but COMMENTED OUT for the initial +# roll-out — see the "DEFERRED" RUN blocks below. Other heavy # extras (LSPs, additional MCP servers, docker-buildx) are -# deliberately deferred. +# deliberately deferred too. +# +# Layer policy: the file is ordered so the LAST RUN steps are the +# smallest and the most frequently invalidated. The hourly cron +# bumps the three agent installers (and the sanity check), and the +# `AGENT_INSTALL_CACHEBUST` ARG ensures Docker rebuilds those +# layers. Everything earlier — Debian base, chromium, docker +# engine, Python lint pins, codestyle clone — stays cached across +# hourly rebuilds, so users pulling `:latest` after a cron run +# only re-download ~tens of MB instead of the full image. # # Published to ghcr.io/wirenboard/agent-vm-template:latest by the # hourly CI workflow (.github/workflows/build-image.yml). Source- @@ -17,7 +31,7 @@ FROM debian:13-slim LABEL org.opencontainers.image.title="agent-vm guest template" -LABEL org.opencontainers.image.description="Guest OCI image that the agent-vm tool (npm: @wirenboard/agent-vm) boots inside each per-project microVM. Contains Debian 13 + Claude Code + OpenCode + Codex + minimal dev tooling. Rebuilt hourly to pick up new agent releases. NOT the agent-vm runtime itself — install that via `npm install -g @wirenboard/agent-vm`." +LABEL org.opencontainers.image.description="Guest OCI image that the agent-vm tool (npm: @wirenboard/agent-vm) boots inside each per-project microVM. Contains Debian 13 + Claude Code + OpenCode + Codex + diag tooling (ssh/ping/etc.) + codestyle-pinned Python linters + /opt/wirenboard/codestyle. Rebuilt hourly to pick up new agent releases. NOT the agent-vm runtime itself — install that via `npm install -g @wirenboard/agent-vm`." # LANG=C.UTF-8: debian:slim ships with NO locale set, leaving the guest in # C/POSIX — which renders a non-ASCII (e.g. Cyrillic) cwd as `M-P…` meta- @@ -35,10 +49,78 @@ ENV DEBIAN_FRONTEND=noninteractive \ # Image-API contract version. Bump on BREAKING image changes only # (new required mount points, changed env-var contracts, removed # binaries, renamed in-VM paths). Routine agent-version refreshes -# do NOT change this. See `crates/agent-vm/src/defaults.rs` for the -# matching range in the binary. +# and tooling additions (build-essential, ssh, etc.) do NOT change +# this — they're additive. See `crates/agent-vm/src/defaults.rs` +# for the matching range in the binary. RUN echo 1 > /etc/agent-vm-image-version +# Conditionally trust a host-supplied CA bundle. Only fires when +# `images/build.sh` is invoked on a host that sits behind a TLS- +# intercept proxy (e.g. agent-vm-inside-agent-vm during local dev, +# or a corporate egress MITM). The build script passes the host CA +# via `--secret id=hostca` IF it exists at +# `/usr/local/share/ca-certificates/microsandbox-ca.crt`. In CI +# (no such file → no secret → empty mount), the `-s` test fails +# and this is a no-op — the production image is unchanged. +# +# `--mount=type=secret` keeps the CA file in the buildkit secret +# store, not in the layer; what IS persisted is the merged +# /etc/ssl/certs/ca-certificates.crt. That's fine: the launcher +# re-imports its per-boot CA at /usr/local/share/ca-certificates/ +# at runtime anyway, so a stale build-time CA in the bundle is +# inert (and removed if a user runs update-ca-certificates). +# +# `CA_SHIM_CACHEBUST`: buildkit does NOT include secret content in +# the RUN cache key (by design — secrets shouldn't bake into +# images). So toggling secret presence across builds doesn't +# invalidate this layer on its own. `images/build.sh` passes the +# CA file's mtime (`stat -c %Y …`) when the secret is included, so +# any change to the CA file (or going from "secret absent" → "secret +# present") shifts the ARG and rebuilds. In CI (no secret, empty +# arg) the layer also no-ops and stays cached. +ARG CA_SHIM_CACHEBUST= +RUN --mount=type=secret,id=hostca,target=/tmp/hostca.crt,required=false \ + : "${CA_SHIM_CACHEBUST}" \ + && if [ -s /tmp/hostca.crt ]; then \ + apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && install -d /usr/local/share/ca-certificates \ + && install -m 0644 /tmp/hostca.crt /usr/local/share/ca-certificates/agent-vm-build.crt \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && echo "==> host CA trusted for this build"; \ + fi + +# Base apt — interpreters, fetchers, search/text utilities, AND +# network/diagnostic CLIs. Bundling these into the existing base +# layer (instead of spinning up a new layer for "ssh + ping") keeps +# the layer count down: every additional layer costs a small +# per-layer materialize overhead in microsandbox even when nothing +# changes, so we only earn a new layer when we can articulate why +# it'd be invalidated independently. +# +# Network/diag tools rationale (engineers debug WB devices over +# SSH and serial; the in-VM agent often needs to confirm a host is +# reachable before chasing logs): +# - iputils-ping, iputils-tracepath, traceroute, mtr-tiny: +# reachability + per-hop latency. +# - net-tools: legacy `ifconfig`, `netstat`, `route` — still +# muscle-memory for most embedded engineers, and many WB docs +# reference them by name. +# - iproute2: modern `ip`, `ss`. Usually preinstalled, listed +# explicitly so an upstream slimming doesn't silently drop it. +# - openssh-client: `ssh`, `scp`, `sftp`, `ssh-keygen`. No server +# — the agent should never accept inbound connections. +# - sshpass: scripted password-auth SSH (lab fixtures, factory +# test rigs); also used by some WB CI scripts. +# - dnsutils: `dig`, `nslookup`, `host`. +# - netcat-openbsd: `nc` for ad-hoc TCP/UDP poking. +# - tcpdump: rare but invaluable when an HTTP MITM looks wrong. +# - gnupg: needed below to add the GitHub CLI apt key (and any +# user-side apt sources `.agent-vm.runtime.sh` adds). +# - less, vim-tiny, file, unzip, zip, rsync, htop, lsof, strace, +# tmux: standard "I'm in a shell, I need a tool" muscle-memory +# set. Each is <5 MB; the bundle is well under 30 MB. RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ @@ -48,6 +130,13 @@ RUN apt-get update \ bash \ python3 python3-pip \ ripgrep fd-find \ + gnupg \ + iputils-ping iputils-tracepath traceroute mtr-tiny \ + net-tools iproute2 \ + openssh-client sshpass \ + dnsutils netcat-openbsd tcpdump \ + less vim-tiny file unzip zip rsync \ + htop lsof strace tmux \ && rm -rf /var/lib/apt/lists/* # Chromium for the chrome-devtools MCP server (Phase 7). The MCP @@ -183,27 +272,6 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* -# Pre-warm chrome-devtools-mcp's npm cache as the chrome user so the -# first browser tool call in any sandbox doesn't pay the multi-second -# `npx -y` fetch cost. `--help` exits cleanly after install/version- -# check; it's the cheapest exercise of the full install path. -# -# Pinned to a known-good version. The runtime MCP config in -# `crates/agent-vm/src/secrets.rs::write_default_claude_root_state` -# pins the SAME version — bump both together. Without the pin the -# cache stops being useful the first time upstream cuts a release: -# `npx -y` re-resolves `@latest` against the registry and re- -# downloads under the writable upper layer. -# -# Best-effort: `|| true` because npm-registry transient failure or a -# bad `--help` exit code shouldn't fail the whole `agent-vm setup`. -# A missing cache costs a multi-second fetch on first browser tool -# call; failing the image build is much worse. -# -# Must come AFTER the nodejs RUN above — npx didn't exist before it. -RUN sudo -u chrome -H npx -y chrome-devtools-mcp@1.0.1 --help >/dev/null 2>&1 \ - || echo "==> pre-warm skipped (will lazy-fetch chrome-devtools-mcp at first MCP call)" - # Docker engine — dockerd + cli + containerd + runc. # # Why each piece: @@ -243,31 +311,312 @@ RUN apt-get update \ && printf '%s\n' '{' ' "storage-driver": "fuse-overlayfs"' '}' \ > /etc/docker/daemon.json +# Pre-warm chrome-devtools-mcp's npm cache as the chrome user so the +# first browser tool call in any sandbox doesn't pay the multi-second +# `npx -y` fetch cost. `--help` exits cleanly after install/version- +# check; it's the cheapest exercise of the full install path. +# +# Pinned to a known-good version. The runtime MCP config in +# `crates/agent-vm/src/secrets.rs::write_default_claude_root_state` +# pins the SAME version — bump both together. Without the pin the +# cache stops being useful the first time upstream cuts a release: +# `npx -y` re-resolves `@latest` against the registry and re- +# downloads under the writable upper layer. +# +# Best-effort: `|| true` because npm-registry transient failure or a +# bad `--help` exit code shouldn't fail the whole `agent-vm setup`. +# A missing cache costs a multi-second fetch on first browser tool +# call; failing the image build is much worse. +# +# Must come AFTER the nodejs RUN above — npx didn't exist before it. +RUN sudo -u chrome -H npx -y chrome-devtools-mcp@1.0.1 --help >/dev/null 2>&1 \ + || echo "==> pre-warm skipped (will lazy-fetch chrome-devtools-mcp at first MCP call)" + +# Wiren Board C/C++ build essentials — DEFERRED. +# +# These two RUN blocks (build-essential family + armhf/arm64 cross +# toolchains) are commented out for the initial roll-out. They +# account for ~466 MiB compressed (~1.6 GB uncompressed) of the +# original image size estimate; deferring them keeps the first +# tooling-image bump lean while the layer ordering / cron-cache +# wins land. Uncomment to re-enable when WB engineers actually +# need the cross-compile path in-VM (the existing host +# `schroot -c bullseye-amd64-sbuild …` workflow still works). +# +# Sourced from the union of debian/control Build-Depends across +# wb-mqtt-serial, wb-mqtt-mbgate, wb-rules, wb-mqtt-confed and the +# `cpp/local/linux-devenv.sh` / `cpp/ci/clang-*-subdirs.sh` scripts +# in `wirenboard/codestyle`. When uncommenting, also re-add the +# corresponding sanity-check probes for clang-format, +# arm-linux-gnueabihf-gcc, aarch64-linux-gnu-gcc, and +# qemu-arm-static at the bottom of the Dockerfile. +# +# - build-essential: gcc/g++/make/dpkg-dev metapackage. +# - debhelper, devscripts, equivs, dpkg-cross: Debian packaging +# primitives. `debhelper (>= 10)` is mandated by every WB +# debian/control; `equivs` is required for `mk-build-deps -ir` +# (the recommended `linux-devenv.sh` install path). +# - pkg-config: standard build-time discovery. +# - clang-format, clang-tidy: the only formatters/linters WB +# codestyle ships configs for (`cpp/config/.clang-format`, +# `.clang-tidy`). `cpp/ci/clang-format-subdirs.sh` and +# `clang-tidy-subdirs.sh` call them directly. +# - libcurl4-openssl-dev, libgtest-dev, libgmock-dev, +# libmodbus-dev, libsystemd-dev: most-frequent C/C++ deps +# across the WB services (modbus serial gateway, MQTT bridge, +# etc). +# - gcovr: pinned in `python/vscode/.devcontainer/devcontainer.json` +# via the `coverage-gutters` extension; wb-mqtt-serial's +# Build-Depends lists it explicitly. +# - gdb-multiarch: install-listed by `cpp/local/linux-devenv.sh`; +# debugging cross-built armhf/arm64 binaries. +# - cmake, ninja-build: not in every WB repo but common enough +# (firmware repo uses cmake) that excluding them would catch +# people off guard. +# - python3-cups, python3-libgpiod, python3-pycurl: per the +# allowed-c-extensions list in `python/config/pyproject.toml`, +# so pylint can resolve imports in WB Python projects without +# spurious E0401. +# RUN apt-get update \ +# && apt-get install -y --no-install-recommends \ +# build-essential \ +# debhelper devscripts equivs dpkg-cross \ +# pkg-config \ +# clang-format clang-tidy \ +# libcurl4-openssl-dev \ +# libgtest-dev libgmock-dev \ +# libmodbus-dev \ +# libsystemd-dev \ +# gcovr \ +# gdb-multiarch \ +# cmake ninja-build \ +# python3-cups python3-libgpiod python3-pycurl \ +# && rm -rf /var/lib/apt/lists/* + +# Wiren Board cross-build toolchains — armhf + arm64 — DEFERRED. +# +# See the deferment note on the build-essentials block above. +# +# Every flagship WB binary ships for one of these two architectures +# (controllers are armhf, newer hardware is arm64); the canonical +# build command in `codestyle/cpp/vscode/.vscode/tasks.json` is +# `schroot -c bullseye-amd64-sbuild -- DEB_HOST_MULTIARCH=arm-… +# make -j12`, and qemu-user-static is what makes the resulting +# binary runnable on x86_64 for tests. +# +# Kept in a SEPARATE layer from the C/C++ essentials above because +# it's the single biggest discretionary addition (~300 MB +# uncompressed / ~258 MiB compressed). A future Dockerfile.slim +# can keep this commented while uncommenting the build-essentials +# block above. +# +# - crossbuild-essential-armhf, crossbuild-essential-arm64: +# gcc-arm-linux-gnueabihf / gcc-aarch64-linux-gnu meta-packages. +# - qemu-user-static: registers binfmt handlers so an `armhf` or +# `arm64` ELF executes under qemu transparently. Required by +# the `[armhf]/[arm64] Run tests for debug` tasks in codestyle. +# - debootstrap, schroot, sbuild: the canonical WB build path is +# `sbuild` inside a `schroot` named `bullseye-amd64-sbuild`. +# The chroot itself is NOT created here (it's per-host, lives +# at /srv/chroot, and requires the schroot config; the user +# creates it on first need with `sbuild-createchroot`). +# RUN apt-get update \ +# && apt-get install -y --no-install-recommends \ +# crossbuild-essential-armhf \ +# crossbuild-essential-arm64 \ +# qemu-user-static \ +# debootstrap schroot sbuild \ +# && rm -rf /var/lib/apt/lists/* + +# Python lint/format toolchain — pinned to the versions in +# `wirenboard/codestyle/python/config/requirements.txt`. Pinning +# matters: codestyle ships a `pyproject.toml` that engineers copy +# into projects, and a black/isort/pylint version skew between +# image and project re-formats files differently → noisy diffs. +# +# `--break-system-packages` because Debian 13 enforces PEP 668 and +# we WANT these system-wide so any in-VM tool finds them. The +# microVM rootfs is throwaway, so there's no system Python we can +# accidentally damage. `--no-cache-dir` shaves ~40 MB off the +# layer (the pip download cache is dead weight in an image). +# +# Versions sourced from +# `codestyle/python/config/requirements.txt` (master). `attrs==23.1.0` +# is per the workaround in `codestyle/python/local/linux-devenv.sh` +# (newer attrs breaks the pinned pylint). `j2cli` is in +# wb-mqtt-serial's Build-Depends and is a one-line install here. +RUN pip3 install --no-cache-dir --break-system-packages \ + black==24.2.0 \ + isort==5.13.2 \ + pylint==3.3.9 \ + pytest-cov==2.10.1 \ + attrs==23.1.0 \ + j2cli + +# Shallow-clone `wirenboard/codestyle` so the per-project +# devenv scripts work without each project re-cloning it. +# +# The script `cpp/local/linux-devenv.sh` and its Python sibling +# both expect to be invoked from inside a project as +# `bash ../codestyle/{cpp,python}/local/linux-devenv.sh`, i.e. +# with codestyle as a sibling directory. Having it at a known +# path inside the image lets a project hook (`.agent-vm.runtime.sh`) +# symlink/copy it into place at launch instead of cloning over +# the proxy on every cold start. +# +# `CODESTYLE_REF` build-arg (default `master`) lets a reproducible +# build pin to a specific ref — `git clone --branch` accepts a +# branch or tag name. The hourly CI cron just builds from master, +# matching the previous behavior. +# +# Hard-fails by default: if GitHub is unreachable the resulting +# image is missing the devenv path that several WB workflows rely +# on, and we'd rather not silently ship that. Source-checkout dev +# builds that set `AGENT_INSTALL_SOFT_FAIL=1` (see images/build.sh, +# auto-set on TLS-intercept hosts) downgrade to a warning. +# +# `AGENT_INSTALL_SOFT_FAIL` is also consumed by the three agent +# installers + the sanity check below; declared here at first use +# so a single toggle covers everything that might legitimately fail +# in a dev sandbox but must succeed in CI. +ARG CODESTYLE_REF=master +ARG AGENT_INSTALL_SOFT_FAIL= +RUN git clone --depth=1 --branch "${CODESTYLE_REF}" \ + https://github.com/wirenboard/codestyle.git \ + /opt/wirenboard/codestyle \ + || { msg="==> wirenboard/codestyle clone FAILED (ref=${CODESTYLE_REF}) — re-clone in-VM with: git clone https://github.com/wirenboard/codestyle.git /opt/wirenboard/codestyle"; \ + if [ -n "${AGENT_INSTALL_SOFT_FAIL:-}" ]; then echo "$msg (soft-fail mode)"; else echo "$msg" >&2; exit 1; fi; } + # Cache-bust for the three agent installers below. The workflow # passes the hourly timestamp tag as the value so each scheduled # build invalidates exactly these RUN layers (and the sanity check -# that follows) while leaving the heavy apt/chromium/docker/node -# layers cached. Without this the GHA layer cache reuses the -# `curl … install.sh | bash` layers indefinitely, so hourly cron -# runs never picked up new claude-code/codex/opencode releases. -# Local `images/build.sh` builds leave the default empty value and -# get the normal Docker layer-cache behaviour. +# that follows) while leaving every earlier layer — Debian base, +# chromium, docker engine, WB build deps, cross toolchains, Python +# lint pins, codestyle clone — cached. Without this the GHA layer +# cache reuses the `curl … install.sh | bash` layers indefinitely, +# so hourly cron runs never picked up new claude-code/codex/ +# opencode releases. Local `images/build.sh` builds leave the +# default empty value and get the normal Docker layer-cache +# behaviour. ARG AGENT_INSTALL_CACHEBUST= -# Claude Code official installer. -RUN curl -fsSL https://claude.ai/install.sh | bash +# Shared helper for the three agent installer RUNs below. Downloads +# the upstream `install.sh`, runs it under the requested shell, and +# emits a uniform soft-fail or hard-fail diagnostic depending on +# `AGENT_INSTALL_SOFT_FAIL`. Keeping the policy in one place means +# any future tweak (extra retries, log format, soft-fail discrimina- +# tion) lives in exactly one spot — the three RUNs that call it are +# trivial one-liners. +# +# Why curl-only (no python3-urllib fallback): both curl and python3's +# `ssl` module link against the same system OpenSSL, so a real TLS- +# layer failure (the curl 56 "SSL_read: unexpected eof" we see from +# inside MITM-proxied dev sandboxes) hits the fallback identically. +# `--retry 5 --retry-all-errors --http1.1` covers the transient cases +# that ARE recoverable. The MITM-sandbox case is what +# `AGENT_INSTALL_SOFT_FAIL=1` exists for. +# +# Written via `printf '%s\n'` because debian-slim's `/bin/sh` is +# POSIX dash, no heredoc niceties. The script is small enough that +# adding a layer for it is essentially free (well under 1 KB). +RUN printf '%s\n' \ + '#!/bin/sh' \ + '# agent-vm-install ' \ + '# - agent identifier (codex, opencode, claude)' \ + '# - sh|bash; the interpreter used to run the installer' \ + '# - upstream installer URL' \ + '# Honors $AGENT_INSTALL_SOFT_FAIL: when non-empty, a download or' \ + '# install failure becomes a warning + exit 0 instead of exit 1.' \ + 'set -eu' \ + 'name=$1; shell=$2; url=$3' \ + 'tmp=$(mktemp)' \ + 'trap "rm -f \"$tmp\"" EXIT INT TERM' \ + 'if curl -fsSL --retry 5 --retry-all-errors --http1.1 "$url" -o "$tmp" \' \ + ' && "$shell" "$tmp"; then' \ + ' exit 0' \ + 'fi' \ + 'msg="==> $name installer FAILED — install in-VM with: curl -fsSL $url | $shell"' \ + 'if [ -n "${AGENT_INSTALL_SOFT_FAIL:-}" ]; then' \ + ' echo "$msg (soft-fail mode; image will ship without $name)"' \ + ' exit 0' \ + 'fi' \ + 'echo "$msg" >&2' \ + 'exit 1' \ + > /usr/local/bin/agent-vm-install \ + && chmod 0755 /usr/local/bin/agent-vm-install \ + && test -s /usr/local/bin/agent-vm-install + +# The three agents each get their own RUN so a single agent +# release only invalidates one layer (~30 MB per agent). Order is +# rough size order (smallest first) so the steady-state worst- +# case download — a fresh release for Claude Code (the biggest) +# — only re-pulls the topmost layer. +# +# Agent installers HARD-FAIL by default — if an upstream installer +# is broken or removed, the hourly cron should NOT silently ship an +# image missing an agent. `AGENT_INSTALL_SOFT_FAIL=1` (build-arg, +# auto-set by images/build.sh on TLS-intercept dev hosts; CI never +# sets it) turns the three RUNs into warnings instead. + +# Codex CLI official installer. +RUN agent-vm-install codex sh \ + https://github.com/openai/codex/releases/latest/download/install.sh # OpenCode official installer. Installs into ~/.opencode/bin; PATH already # includes it. Symlink into /usr/local/bin so non-login shells find it too. -RUN curl -fsSL https://opencode.ai/install | bash \ - && ln -sf /root/.opencode/bin/opencode /usr/local/bin/opencode +# The symlink is best-effort — under soft-fail mode the install may have +# been skipped, in which case there's nothing to link. +RUN agent-vm-install opencode bash https://opencode.ai/install \ + && { [ -x /root/.opencode/bin/opencode ] \ + && ln -sf /root/.opencode/bin/opencode /usr/local/bin/opencode \ + || true; } -# Codex CLI official installer. -RUN curl -fsSL https://github.com/openai/codex/releases/latest/download/install.sh | sh +# Claude Code official installer. +RUN agent-vm-install claude bash https://claude.ai/install.sh -# Sanity check at build time so a broken installer surfaces before we push. -RUN claude --version && opencode --version && codex --version \ - && dockerd --version && docker --version && containerd --version && runc --version +# Sanity check at build time. Every command is invoked directly +# (`tool --version >/dev/null`) so a present-but-broken binary — +# corrupt download, broken symlink target, missing libc symbol — +# fails the build instead of slipping through a `command -v` exists- +# check. `set -e` propagates the first failure. +# +# Agent CLIs (claude/opencode/codex) are HARD-required unless +# `AGENT_INSTALL_SOFT_FAIL` is set, in which case they're SOFT +# (missing → warning, not failure) — matches the policy on the +# installer RUNs above. /opt/wirenboard/codestyle follows the same +# policy (its clone RUN above also honors AGENT_INSTALL_SOFT_FAIL). +RUN set -e \ + && echo "== HARD requirements ==" \ + && dockerd --version >/dev/null \ + && docker --version >/dev/null \ + && containerd --version >/dev/null \ + && runc --version >/dev/null \ + && black --version >/dev/null \ + && isort --version >/dev/null \ + && pylint --version >/dev/null \ + && ssh -V >/dev/null 2>&1 \ + && sshpass -V >/dev/null 2>&1 \ + && ping -V >/dev/null 2>&1 \ + && { if [ -d /opt/wirenboard/codestyle ]; then \ + echo " codestyle clone: OK"; \ + elif [ -n "${AGENT_INSTALL_SOFT_FAIL:-}" ]; then \ + echo " codestyle clone: MISSING (soft-fail mode)"; \ + else \ + echo " codestyle clone: MISSING — hard sanity failure" >&2; exit 1; \ + fi; } \ + && echo "== agent CLIs ==" \ + && for bin in claude opencode codex; do \ + if "$bin" --version >/dev/null 2>&1; then \ + ver=$("$bin" --version 2>&1 | head -1); \ + echo " $bin: $ver"; \ + elif [ -n "${AGENT_INSTALL_SOFT_FAIL:-}" ]; then \ + echo " $bin: MISSING (soft-fail mode)"; \ + else \ + echo " $bin: MISSING — hard sanity failure" >&2; \ + exit 1; \ + fi; \ + done \ + && echo "== sanity OK ==" # Smoke entrypoint; the launcher overrides this in Phase 2. CMD ["/bin/bash"] diff --git a/images/build.sh b/images/build.sh index c51612e..2ccccd2 100755 --- a/images/build.sh +++ b/images/build.sh @@ -125,8 +125,53 @@ build_and_push() { # win. `registry.insecure=true` lets us push to the loopback HTTP # registry. We use `compression-level=3` (zstd's default) — the # bench shows diminishing returns past that for binary-heavy layers. + + # If the host is itself behind a TLS-intercept proxy (agent-vm- + # inside-agent-vm during local dev, or a corporate egress MITM), + # the buildkit container's outbound HTTPS sees the proxy's CA and + # curl/apt fail with "unable to verify the legitimacy of the + # server". Detect the host CA and: + # - pass it as a buildx secret (the Dockerfile imports it + # conditionally; no-op when the secret is absent), + # - key the RUN-cache invalidation off its mtime (CA_SHIM_ + # CACHEBUST — see Dockerfile comment for why secret content + # alone doesn't invalidate the cache), + # - run the RUN steps in the host network namespace, because + # buildkit's default bridge stack drops some HTTPS connec- + # tions (curl 56 `SSL_read: unexpected eof`) mid-redirect + # through the MITM proxy. + # Production CI has no such host CA and skips all of this. + local extra=() + local host_ca="${AGENT_VM_BUILD_HOST_CA:-/usr/local/share/ca-certificates/microsandbox-ca.crt}" + local mitm_detected= + if [ -f "${host_ca}" ]; then + echo "==> Including host CA ${host_ca} as buildx secret (TLS-intercept proxy detected)" + extra+=(--secret "id=hostca,src=${host_ca}") + extra+=(--build-arg "CA_SHIM_CACHEBUST=$(stat -c %Y "${host_ca}")") + extra+=(--allow "network.host") + extra+=(--network "host") + mitm_detected=1 + fi + + # AGENT_INSTALL_SOFT_FAIL — independent toggle (not tied to CA + # detection) so a clean-network developer rebuilding during an + # upstream installer outage can opt in, and someone debugging + # installer changes on a MITM host can force hard-fail with + # `AGENT_VM_BUILD_SOFT_FAIL_AGENTS=0`. + # + # Default policy: MITM-detected hosts → soft-fail (the same TLS + # interception that triggers the CA shim also hits curl 56 on + # some GitHub release-asset URLs); clean hosts → hard-fail + # (matching production CI). + local soft_fail="${AGENT_VM_BUILD_SOFT_FAIL_AGENTS:-${mitm_detected}}" + if [ -n "${soft_fail}" ] && [ "${soft_fail}" != "0" ]; then + echo "==> Soft-fail mode enabled for agent installers + codestyle clone (AGENT_VM_BUILD_SOFT_FAIL_AGENTS=0 to disable)" + extra+=(--build-arg "AGENT_INSTALL_SOFT_FAIL=1") + fi + docker buildx build \ -t "${IMAGE_TAG}" \ + "${extra[@]}" \ --output "type=registry,push=true,registry.insecure=true,compression=zstd,compression-level=3,force-compression=true" \ -f "${SCRIPT_DIR}/Dockerfile" \ "${SCRIPT_DIR}"