#!/usr/bin/env bash # Smoke-test a freshly-built opencode-devbox image. # # Verifies: # - Core binaries are on PATH and runnable # - opencode itself starts and prints a version # - Entrypoint runs cleanly as non-root after UID adjustment # - Generated opencode.json has the expected shape # - MCP wrapper works (when mempalace is installed) # # Usage: ./scripts/smoke-test.sh [--variant base|omos|with-pi|omos-with-pi|pi-only] # # Exit codes: # 0 all checks passed # 1 one or more checks failed set -euo pipefail IMAGE="${1:-}" VARIANT="base" if [ "${2:-}" = "--variant" ]; then VARIANT="${3:-base}" fi if [ -z "$IMAGE" ]; then echo "usage: $0 [--variant base|omos|with-pi|omos-with-pi|pi-only]" >&2 exit 2 fi FAILED=0 pass() { echo " ✓ $1"; } fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); } warn() { echo " ⚠ $1" >&2; } # Registration assertions (fork/recall installed by the BASE image's # entrypoint-user.sh via `pi install /opt/`) depend on the base, not the # variant layer built here. validate.yml builds variants FROM the published # base-latest, which can lag the entrypoint in the current commit (the base # only rebuilds on a release tag), so a stale base-latest would red the # push-to-main run with a false negative. These checks are therefore warn-only # by default; the release pipeline (docker-publish-split.yml) builds the base # fresh in the same run and sets STRICT_REGISTRATION=1 to enforce them hard. # The build-time /opt + node_modules checks below stay hard in every path — # those are produced by the variant layer and must always be correct. STRICT_REGISTRATION="${STRICT_REGISTRATION:-0}" run() { # Run a command inside the image and capture its output. # First arg is a label, rest is the shell command. local label="$1"; shift local out if out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$*" 2>&1); then pass "$label ($(echo "$out" | head -1))" else fail "$label: $out" fi } # Stricter version of `run` that also asserts an expected substring in # the command's stdout. Used to catch the "image bytes silently identical # to previous release" class of regression — Docker layer-cache hit on # a bare `npm install -g ` (or @latest) because the build-arg # string is identical across builds, even when 'latest' would have # resolved differently. Discovered in pi-devbox 2026-05-23 (every # release v0.74.0..v0.75.5 shipped the same image bytes); preventatively # applied here for PI_VERSION + OMOS_VERSION. run_expect() { local label="$1"; local cmd="$2"; local expect="$3" local out out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1) || true if echo "$out" | grep -Fq "$expect"; then pass "$label (got $expect)" else fail "$label — expected substring '$expect', got: $out" fi } echo "=== Smoke test: $IMAGE (variant: $VARIANT) ===" echo echo "-- Resolved component versions --" # Prints the actual version of every floating component so CI logs # always record what got baked into this image, even when Dockerfile # ARGs default to "latest". docker run --rm --entrypoint="" "$IMAGE" sh -c ' if command -v opencode >/dev/null 2>&1; then printf " %-15s %s\n" "opencode" "$(opencode --version 2>&1 | head -1)" fi if command -v pi >/dev/null 2>&1; then printf " %-15s %s\n" "pi" "$(pi --version 2>&1 | head -1)" fi printf " %-15s %s\n" "node" "$(node --version)" printf " %-15s %s\n" "npm" "$(npm --version)" printf " %-15s %s\n" "nvim" "$(nvim --version | head -1)" printf " %-15s %s\n" "bat" "$(bat --version)" printf " %-15s %s\n" "eza" "$(eza --version | head -2 | tail -1)" printf " %-15s %s\n" "zoxide" "$(zoxide --version)" printf " %-15s %s\n" "uv" "$(uv --version)" printf " %-15s %s\n" "fzf" "$(fzf --version)" printf " %-15s %s\n" "fd" "$(fd --version)" printf " %-15s %s\n" "rg" "$(rg --version | head -1)" printf " %-15s %s\n" "gosu" "$(gosu --version)" printf " %-15s %s\n" "git-lfs" "$(git-lfs --version)" printf " %-15s %s\n" "git-crypt" "$(git-crypt --version 2>&1 | head -1)" printf " %-15s %s\n" "gitleaks" "$(gitleaks version 2>&1 | head -1)" printf " %-15s %s\n" "gitea-mcp" "$(gitea-mcp --version 2>&1 | head -1)" printf " %-15s %s\n" "aws" "$(aws --version 2>&1)" if command -v bun >/dev/null 2>&1; then printf " %-15s %s\n" "bun" "$(bun --version)" fi if command -v mempalace >/dev/null 2>&1; then printf " %-15s %s\n" "mempalace" "$(mempalace --version 2>&1 | head -1 || echo installed)" fi if command -v mempalace-session >/dev/null 2>&1 && [ -d /opt/mempalace-toolkit ]; then printf " %-15s %s\n" "toolkit" "$(git -C /opt/mempalace-toolkit rev-parse --short HEAD 2>/dev/null || echo installed)" fi ' echo echo "-- Core binaries --" # opencode is gated on INSTALL_OPENCODE=true (default). When absent, the # image is a pi-only build (or a pure base — no harness at all). if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v opencode" >/dev/null 2>&1; then run "opencode" "opencode --version" else echo " - opencode not installed (INSTALL_OPENCODE=false)" fi run "node" "node --version" run "npm" "npm --version" run "git" "git --version" run "nvim" "nvim --version | head -1" run "bat" "bat --version" run "eza" "eza --version | head -1" run "zoxide" "zoxide --version" run "uv" "uv --version" run "uvx" "uvx --version" run "rustup-init" "rustup-init --version" run "fzf" "fzf --version" run "fd" "fd --version" run "rg" "rg --version | head -1" run "jq" "jq --version" run "git-crypt" "git-crypt --version | head -1" run "gitleaks" "gitleaks version" run "aws" "aws --version" run "gitea-mcp" "gitea-mcp --version" run "gosu" "gosu --version" run "tmux" "tmux -V" # SSH ControlMaster baked defaults: the config file must exist (image-level) # and ssh -G must report ControlPath rooted at /tmp/sshcm/ for an arbitrary # host. Catches both regressions: someone removing the conf file, OR something # else later in the config chain shadowing the ControlPath setting. run "ssh-config-cm-file" "test -f /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf" run_expect "ssh-config-cm-path" "ssh -G example.invalid 2>/dev/null | grep -i ^controlpath" "/tmp/sshcm/" echo echo "-- Optional / variant-gated --" # mempalace: present unless built with INSTALL_MEMPALACE=false if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev/null 2>&1; then run "mempalace" "mempalace --help | head -1" run "mempalace-mcp" "test -x /usr/local/bin/mempalace-mcp && readlink /usr/local/bin/mempalace-mcp" else echo " - mempalace not installed (INSTALL_MEMPALACE=false)" fi # mempalace-toolkit wrappers: present unless built with INSTALL_MEMPALACE_TOOLKIT=false # Gated on mempalace presence — wrappers are useless without the CLI. if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace && command -v mempalace-session" >/dev/null 2>&1; then run "mempalace-session (toolkit)" "mempalace-session --help | head -1" run "mempalace-docs (toolkit)" "mempalace-docs --help | head -1" run "toolkit symlink target" "test -L /usr/local/bin/mempalace-session && readlink /usr/local/bin/mempalace-session" elif docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev/null 2>&1; then echo " - mempalace-toolkit not installed (INSTALL_MEMPALACE_TOOLKIT=false)" fi # pi: present when built with INSTALL_PI=true. Verifies pi itself plus # the runtime-deployed pi-toolkit + pi-extensions + mempalace bridge # symlinks under ~/.pi/agent/. Note: extension symlinks are created by # entrypoint-user.sh on first start, so we test by running the entry # point chain (not just `docker run --entrypoint=""`). if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&1; then if [ -n "${EXPECTED_PI_VERSION:-}" ]; then run_expect "pi version matches build-arg" "pi --version" "$EXPECTED_PI_VERSION" else run "pi" "pi --version" fi run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD" run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD" # pi-fork (fork tool) + pi-observational-memory (recall tool): cloned to # /opt with node_modules baked at build time (a local-path `pi install` does # NOT npm-install, so deps MUST already be present for the extension to load). run "pi-fork clone + node_modules" \ "test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules && echo ok" run "pi-observational-memory clone + node_modules" \ "test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules && echo ok" # Run the full entrypoint as developer to verify install.sh deployment. # Spin up a long-running container so we can `docker exec` into it from # the host — the `run` helper above invokes commands INSIDE the image # and has no docker CLI to nest with. CID=$(docker run -d --rm "$IMAGE" tail -f /dev/null) trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT # Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions. # Marker: keybindings.json symlink lands once pi-toolkit/install.sh has run. # Up to 30s — omos-with-pi has more setup work than base+pi. for _ in $(seq 1 30); do if docker exec "$CID" test -L /home/developer/.pi/agent/keybindings.json 2>/dev/null; then break fi sleep 1 done exec_test() { local label="$1"; shift local out if out=$(docker exec -u developer "$CID" sh -c "$*" 2>&1); then pass "$label ($(echo "$out" | head -1))" else fail "$label: $out" fi } # Like exec_test but warn-only unless STRICT_REGISTRATION=1 (see note at top). exec_test_reg() { local label="$1"; shift local out if out=$(docker exec -u developer "$CID" sh -c "$*" 2>&1); then pass "$label ($(echo "$out" | head -1))" elif [ "$STRICT_REGISTRATION" = "1" ]; then fail "$label: $out" else warn "$label (warn-only — stale base-latest? set STRICT_REGISTRATION=1 to enforce): $out" fi } exec_test "~/.pi/agent/keybindings.json (pi-toolkit)" \ 'test -L $HOME/.pi/agent/keybindings.json && echo ok' exec_test "~/.pi/agent/extensions/*.ts ≥ 4 (pi-extensions)" \ 'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"' exec_test "~/.pi/agent/extensions/mempalace.ts (bridge)" \ 'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok' exec_test "~/.pi/agent/settings.json (template bootstrap)" \ 'test -f $HOME/.pi/agent/settings.json && echo ok' # pi-fork + pi-observational-memory are registered by entrypoint-user.sh via # `pi install /opt/` (records a relative path into settings.json # packages). That runs slightly after the keybindings marker, so wait for it. for _ in $(seq 1 15); do if docker exec "$CID" grep -q pi-observational-memory \ /home/developer/.pi/agent/settings.json 2>/dev/null; then break fi sleep 1 done exec_test_reg "pi-fork registered in settings.json (fork tool)" \ 'grep -q pi-fork $HOME/.pi/agent/settings.json && echo ok' exec_test_reg "pi-observational-memory registered in settings.json (recall tool)" \ 'grep -q pi-observational-memory $HOME/.pi/agent/settings.json && echo ok' docker rm -f "$CID" >/dev/null 2>&1 || true trap - EXIT else echo " - pi not installed (INSTALL_PI=false)" fi # bun: only in the omos and omos-with-pi variants if [ "$VARIANT" = "omos" ] || [ "$VARIANT" = "omos-with-pi" ]; then run "bun (omos)" "bun --version" run "bunx symlink (omos)" "test -L /usr/local/bin/bunx && readlink /usr/local/bin/bunx" # oh-my-opencode-slim is npm-installed globally (not a bun install); # verify it shows up in the global module list. We must explicitly point # npm at the system prefix (/usr) here: the image's NPM_CONFIG_PREFIX env # is set to /home/developer/.pi/npm-global so user-installed packages # land on the persistent volume — which means a default `npm ls -g` # queries the user prefix and would miss the baked binaries even though # they're correctly on PATH at /usr/bin. run "oh-my-opencode-slim" "NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim" if [ -n "${EXPECTED_OMOS_VERSION:-}" ]; then run_expect "omos version matches build-arg" \ "NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim" \ "$EXPECTED_OMOS_VERSION" fi else if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v bun" >/dev/null 2>&1; then fail "bun should NOT be in base image but was found" else pass "bun correctly absent from base image" fi fi echo echo "-- Entrypoint behaviour --" # Generate-config script exists and has valid syntax. run "generate-config.py exists" \ "test -x /usr/local/lib/opencode-devbox/generate-config.py && python3 -m py_compile /usr/local/lib/opencode-devbox/generate-config.py && echo ok" # Entrypoint drops to developer user and runs a trivial command. # Writes the result to a file inside the container so we don't have to # disentangle entrypoint log output from command stdout on the host. label="entrypoint drops to developer" tmpout=$(mktemp) if docker run --rm -e OPENCODE_PROVIDER= "$IMAGE" \ sh -c 'whoami > /tmp/who && cat /tmp/who' > "$tmpout" 2>/dev/null; then # The last line of stdout is the whoami output. Entrypoint log lines # (MemPalace init, "Adjusted developer UID", etc.) go to stderr or # get printed before our sh command runs. actual=$(tail -1 "$tmpout" | tr -d '[:space:]') if [ "$actual" = "developer" ]; then pass "$label" else fail "$label: expected 'developer', got '$actual' (full output: $(cat "$tmpout"))" fi else fail "$label: container failed" fi rm -f "$tmpout" # Config generation with anthropic provider writes valid JSONC with the # expected shape. The script's log message goes to stderr (line 1 of # generate-config.py uses file=sys.stderr) so capturing only stdout # gives us clean JSONC. We strip // comments before validating JSON. label="generate-config produces valid opencode.jsonc" tmp=$(mktemp -d) if docker run --rm \ -e OPENCODE_PROVIDER=anthropic \ -e HOME=/tmp/home \ --entrypoint="" \ "$IMAGE" sh -c ' mkdir -p /tmp/home python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null cat /tmp/home/.config/opencode/opencode.jsonc ' > "$tmp/out.jsonc" 2>/dev/null; then # Strip single-line // comments for JSON validation (respecting strings) if python3 -c " import re, json, sys text = open('$tmp/out.jsonc').read() # Match either a string literal or a // comment; keep strings, drop comments pattern = r'\"(?:\\\\.|[^\"\\\\])*\"|//[^\n]*' stripped = re.sub(pattern, lambda m: m.group(0) if m.group(0).startswith('\"') else '', text) c = json.loads(stripped) assert c['model'].startswith('anthropic/'), c assert c['autoupdate'] is False assert c['share'] == 'disabled' assert 'context7' in c.get('mcp', {}), 'context7 MCP not registered' " 2>&1; then pass "$label" else fail "$label: output doesn't match expected shape: $(cat "$tmp/out.jsonc")" fi else fail "$label: container failed: $(cat "$tmp/out.jsonc")" fi # Config generation is idempotent — running twice must not overwrite. # Tests both legacy .json and new .jsonc detection. label="generate-config never overwrites existing config" if docker run --rm \ -e OPENCODE_PROVIDER=anthropic \ -e HOME=/tmp/home \ --entrypoint="" \ "$IMAGE" sh -c ' mkdir -p /tmp/home/.config/opencode echo "{\"sentinel\": \"user-config\"}" > /tmp/home/.config/opencode/opencode.json python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null cat /tmp/home/.config/opencode/opencode.json ' 2>/dev/null | grep -q '"sentinel": "user-config"'; then pass "$label" else fail "$label: existing config was modified!" fi rm -rf "$tmp" echo echo "-- Image size --" SIZE_BYTES=$(docker image inspect --format='{{.Size}}' "$IMAGE") SIZE_MB=$((SIZE_BYTES / 1024 / 1024)) echo " Uncompressed size: ${SIZE_MB} MB" # Thresholds (uncompressed): base 2500 MB, omos 3300 MB, with-pi adds ~150 MB. # omos bumped 3000→3200 on v1.14.31c — mempalace-toolkit bake-in pushed the # baseline; bumped 3200→3300 on v1.15.0 — opencode 1.15.0 came in at # 3206 MB, leaving zero headroom for routine apt-get upgrade drift. # omos-with-pi bumped 3400→3500 on v1.15.0 alongside the omos bump. # omos-with-pi bumped 3500→3700 on v1.15.4b — omos+pi compounded as both # upstream packages grew (opencode 1.15.0→1.15.4, pi 0.74.0→0.75.3) and # the variant landed just over 3500 in v1.15.4's smoke. # with-pi 2700→2900 and omos-with-pi 3700→3900: baking pi-fork + # pi-observational-memory node_modules into /opt (fork pulls its # @earendil-works peer deps, ~150 MB) adds to both pi-bearing variants. # base 2500→2600 on v1.15.13c — base crept to 2506 MB (LAN-access script + # updated entrypoint + routine apt-get upgrade drift), tripping the # deliberately zero-headroom 2500 ceiling and skipping promote-base-latest. # omos variant to ~3.1 GB. Functional smoke checks all pass; this is a # guardrail, not a performance limit. THRESHOLD=2600 [ "$VARIANT" = "omos" ] && THRESHOLD=3300 [ "$VARIANT" = "with-pi" ] && THRESHOLD=2900 [ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=3900 # pi-only = with-pi minus opencode (its platform binary is ~145 MB), so it # lands a bit under base. Threshold 2750 leaves the same headroom pattern. [ "$VARIANT" = "pi-only" ] && THRESHOLD=2750 if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT" else pass "image size ${SIZE_MB} MB within threshold ${THRESHOLD} MB" fi echo if [ "$FAILED" -gt 0 ]; then echo "=== FAILED: $FAILED check(s) ===" >&2 exit 1 fi echo "=== PASSED ==="