37960186c6
Not tagged yet; this is the v2.9.0 changeset landing on main. Added - agent-browser + a Playwright-managed headless Chromium in the base (~625 MB after deleting the redundant chromium_headless_shell build), so an agent can drive a real browser and VERIFY front-end work instead of assuming it renders. Ported from pi-devbox. AGENT_BROWSER_EXECUTABLE_PATH points at the stable symlink /usr/local/bin/agent-chrome, which the Dockerfile resolves with `find` rather than hardcoding: Playwright's browser dir is per-version AND per-arch (chrome-linux on arm64, chrome-linux64 on amd64), and the headless shell binary is named chrome-headless-shell so `-name chrome` skips it. - opencode-devbox-version: a reader for the build manifest. The image has baked ground truth to /etc/opencode-devbox/build-manifest.json for several releases, but nothing read it and nothing printed it — so "which image am I running?" meant knowing the path by heart. Three modes (--json/--quiet/human), plus a live-vs-baked drift check, because NPM_CONFIG_PREFIX points at the persistent config volume and a user `npm install -g opencode` can shadow the baked binary. entrypoint-user.sh prints it as its first output. - ENV COLORTERM=truecolor, completing a true-colour story the image already half-shipped (terminfo entries + Neovim termguicolors, but no capability advertisement, so bat/delta fell back to 256 colours). - Smoke assertions for agent-browser, that agent-chrome resolves to an executable (catches a Playwright layout change, not just a dangling symlink), COLORTERM, the manifest's release_tag, and all three version-command modes. Changed - opencode 1.17.20 -> 1.18.13. Verified by diffing upstream source, not release notes: core config.ts, config/provider.ts and schema.json are byte-identical, so generate-config.py needs no change. 1.18.13 (published mid-audit) was re-verified separately — 249 files in the compare payload, under GitHub's 300-file cap, so the list is complete rather than truncated; content is the Electron app plus localisation; the five contract-surface files hash identical at both tags. The bg-subagents removal trigger has NOT fired: runtime-flags.ts still gates the flag behind OPENCODE_EXPERIMENTAL at all three tags. - yq: dropped Debian's apt package (the unrelated Python kislyuk/yq — jq syntax, 3.x line) for mikefarah's Go yq v4 from GitHub. The cloud-init repo's provision.sh/deploy.sh need v4 syntax, and THIRD_PARTY.md already credited "yq (mikefarah)" while the image shipped the Python one, so this also closes a documented-vs-shipped mismatch. Smoke pins the contract to mikefarah v4. BEHAVIOUR CHANGE for any in-image script calling yq with jq-style syntax. - mempalace pin 3.5.0 -> 3.6.0, in lockstep with pi-devbox (5724302). Reviewed for MCP tool-schema changes before bumping — none, and nothing touches diary_write. - Default models -> claude-opus-5 (anthropic, and bedrock's global.anthropic.claude-opus-5) and openai/gpt-5.6. gpt-5.4 had gone stale: gpt-5.6 shipped four days before the v2.8.0 cut. Affects only new containers with no OPENCODE_MODEL and no existing config. - Smoke size thresholds +650 MB (base 2950->3600, omos 3650->4300), sized to keep the same ~250 MB headroom so the guardrail still catches runaway growth rather than routine apt drift. Do NOT copy pi-devbox's number: it sums `docker history`, this repo uses `docker image inspect .Size`. Documentation - New README section "Choosing a provider and model", making explicit that the baked defaults are only defaults and nobody is locked to Anthropic/Bedrock, including the three real gotchas: defaults seed only a NEW config, an existing opencode.jsonc on the persistent volume is never rewritten, and switching model needs no rebuild. - New README section "Browser automation (agent-browser)"; opencode-devbox-version documented under Build provenance; COLORTERM under Terminal compatibility. - README Build Args table drift fixed — FOUR missing args added (AGENT_BROWSER_VERSION, PLAYWRIGHT_VERSION, YQ_VERSION and GITLEAKS_VERSION, the last of which had existed as an ARG but was never listed), plus rows for the two pinned args absent entirely (MEMPALACE_VERSION, DEBIAN_VERSION), plus a refreshed stale OPENCODE_VERSION example. Third consecutive release to find drift in this table. - AGENTS.md: the stale MemPalace anyOf convention rewritten. It described a perl RUN block already DELETED at the 3.5.0 bump and asserted "PyPI latest is 3.4.0 (== our pin), no release contains the fix yet, the workaround must stay" — all three false. Replaced with a pin-review rule. Two new conventions added: the agent-browser/Chromium size coupling, and the yq identity trap. - THIRD_PARTY.md: agent-browser, Playwright, Chromium. Verified locally with the CI-pinned hadolint 2.14.0 and actionlint 1.7.7, the shell guard, DOCKER_HUB.md sync, bash -n, py_compile, and by generating the config for all three providers.
460 lines
22 KiB
Bash
Executable File
460 lines
22 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Smoke-test a freshly-built opencode-devbox image.
|
|
#
|
|
# Verifies:
|
|
# - Core binaries are on PATH and runnable
|
|
# - non-modal editors nano + micro are present (alongside nvim)
|
|
# - 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)
|
|
# - typst PDF engine for pandoc — `pandoc --pdf-engine=typst`
|
|
# - terminfo for modern emulators: xterm-kitty, xterm-ghostty, wezterm,
|
|
# alacritty, foot (kitty-terminfo + ncurses-term + compiled ghostty alias)
|
|
# - Neovim true-colour default (sysinit.vim termguicolors)
|
|
#
|
|
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos]
|
|
#
|
|
# 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 <image> [--variant base|omos]" >&2
|
|
exit 2
|
|
fi
|
|
|
|
FAILED=0
|
|
pass() { echo " ✓ $1"; }
|
|
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
|
warn() { echo " ⚠ $1" >&2; }
|
|
|
|
# Registration assertions for fork/recall were removed in v2.0.0 along with
|
|
# pi. STRICT_REGISTRATION is retained as an inert env var for backward
|
|
# compatibility with any external caller that still sets it; it has no
|
|
# effect now that no pi packages are deployed.
|
|
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 <pkg>` (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
|
|
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 pure base with 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 "nvim true-colour default (sysinit.vim)" "nvim --headless -c 'lua os.exit(vim.o.termguicolors and 0 or 1)'"
|
|
run "kitty-terminfo" "infocmp -x xterm-kitty >/dev/null 2>&1"
|
|
run "terminfo: modern emulators (ncurses-term)" 'for t in wezterm alacritty foot ghostty st-256color; do infocmp -x "$t" >/dev/null 2>&1 || exit 1; done'
|
|
run "terminfo: xterm-ghostty alias (tic)" "infocmp -x xterm-ghostty >/dev/null 2>&1"
|
|
run "nano" "nano --version | head -1"
|
|
run "micro" "micro --version"
|
|
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"
|
|
# yq MUST be mikefarah's Go yq v4, NOT Debian's `yq` apt package (the unrelated
|
|
# Python kislyuk/yq — a jq wrapper on a 3.x line with incompatible syntax). v2.9.0
|
|
# swapped the apt package for the mikefarah binary. Pinning the contract to major
|
|
# v4 makes BOTH a regression to the Python package AND a surprise future yq v5
|
|
# fail CI loudly, instead of silently breaking the cloud-init repo's
|
|
# provision.sh/deploy.sh which require v4 syntax.
|
|
run "yq is mikefarah v4" "yq --version | grep -qE 'mikefarah.*version v4' && yq --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"
|
|
run "pandoc" "pandoc --version | head -1"
|
|
run "typst" "typst --version"
|
|
# agent-browser + its Chromium. The ENV must point at a resolvable executable:
|
|
# AGENT_BROWSER_EXECUTABLE_PATH -> /usr/local/bin/agent-chrome -> Playwright's
|
|
# per-version, per-ARCH chrome binary. Asserting the resolved target is
|
|
# executable catches a Playwright layout change (the reason the Dockerfile
|
|
# `find`s the binary instead of hardcoding the path) rather than just checking
|
|
# that a dangling symlink exists.
|
|
run "agent-browser" "agent-browser --version"
|
|
run "agent-chrome resolves to an executable" \
|
|
"test -x \"\$(readlink -f /usr/local/bin/agent-chrome)\" && readlink -f /usr/local/bin/agent-chrome"
|
|
run_expect "AGENT_BROWSER_EXECUTABLE_PATH baked" \
|
|
"printenv AGENT_BROWSER_EXECUTABLE_PATH" "/usr/local/bin/agent-chrome"
|
|
run "pandoc+typst PDF engine" "printf '# hi\n' | pandoc --pdf-engine=typst -o /tmp/_smoke.pdf - && test -s /tmp/_smoke.pdf; rm -f /tmp/_smoke.pdf"
|
|
run "graphviz (dot)" "dot -V"
|
|
run "tldr (tealdeer)" "tldr --version"
|
|
run "dot-watch" "test -x /usr/local/bin/dot-watch && bash -n /usr/local/bin/dot-watch && echo ok"
|
|
|
|
# Background subagents: opencode gates them behind this experimental env var,
|
|
# and OMOS V2+ default orchestration depends on it. Baked ON as an ENV in
|
|
# Dockerfile.base — assert it's present in the image environment (both variants).
|
|
run_expect "bg-subagents env baked" "printenv OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS" "true"
|
|
# True-colour advertisement for colour-aware tools (bat, delta, Neovim's
|
|
# auto-detect). Pairs with the terminfo entries + sysinit.vim termguicolors.
|
|
run_expect "COLORTERM env baked" "printenv COLORTERM" "truecolor"
|
|
|
|
# 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
|
|
|
|
# bun: only in the omos variant
|
|
if [ "$VARIANT" = "omos" ]; 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/.config/opencode/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
|
|
# OMOS bundled-skills SOURCE must be present at the fixed image path that
|
|
# entrypoint-user.sh symlinks into ~/.agents/skills/ on container start. If
|
|
# upstream restructures the package (moves src/skills), the runtime symlinks
|
|
# would dangle SILENTLY and the skills would just disappear — assert the
|
|
# source here so that breakage fails the build loudly instead. We check the
|
|
# source (not the runtime symlinks) because smoke tests run with
|
|
# --entrypoint="" and never execute entrypoint-user.sh.
|
|
run "omos bundled-skills source" \
|
|
"for n in clonedeps codemap deepwork oh-my-opencode-slim simplify; do test -d /usr/lib/node_modules/oh-my-opencode-slim/src/skills/\$n || exit 1; done && echo ok"
|
|
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 "-- Image-baked fallback skills + harness instruction --"
|
|
# Baked under /usr/local/share (base image, both variants). entrypoint-user.sh
|
|
# symlinks these into ~/.agents/skills/ and ~/.config/opencode/instructions/ on
|
|
# container start; assert the SOURCE here (smoke runs with --entrypoint="").
|
|
run "baked opencode-devbox-environment skill" \
|
|
"test -f /usr/local/share/opencode-devbox/skills/opencode-devbox-environment/SKILL.md && echo ok"
|
|
run "baked mempalace fallback skill" \
|
|
"test -f /usr/local/share/opencode-devbox/skills/mempalace/SKILL.md && echo ok"
|
|
run "baked harness instruction (opencode-devbox.md)" \
|
|
"test -f /usr/local/share/opencode-devbox/instructions/opencode-devbox.md && echo ok"
|
|
|
|
echo
|
|
echo "-- Build provenance (manifest + OCI labels) --"
|
|
run "/etc/opencode-devbox/build-manifest.json present" \
|
|
"test -f /etc/opencode-devbox/build-manifest.json"
|
|
run_expect "manifest records opencode component" \
|
|
"cat /etc/opencode-devbox/build-manifest.json" '"opencode"'
|
|
run_expect "manifest records opencode_version" \
|
|
"cat /etc/opencode-devbox/build-manifest.json" '"opencode_version"'
|
|
run_expect "manifest records mempalace-toolkit component" \
|
|
"cat /etc/opencode-devbox/build-manifest.json" '"mempalace-toolkit"'
|
|
run_expect "manifest records release_tag" \
|
|
"cat /etc/opencode-devbox/build-manifest.json" '"release_tag"'
|
|
# The manifest is only useful if something can READ it. v2.9.0 added
|
|
# opencode-devbox-version as that reader (and entrypoint-user.sh prints it at
|
|
# container start), so assert the command itself works in all three modes —
|
|
# otherwise the manifest stays an invisible artifact, which is what it was for
|
|
# every release before this one.
|
|
run_expect "opencode-devbox-version --json emits the manifest" \
|
|
"opencode-devbox-version --json" '"release_tag"'
|
|
run "opencode-devbox-version --quiet" "opencode-devbox-version --quiet"
|
|
run "opencode-devbox-version (human)" "opencode-devbox-version | head -1"
|
|
# Every resolved component must be a real value, never the 'unknown'
|
|
# sentinel that rev()/version lookups emit on failure. (oh-my-opencode-slim
|
|
# is JSON null in the base variant — that is expected, not 'unknown'.)
|
|
run "manifest has no unresolved ('unknown') components" \
|
|
"! grep -q '\"unknown\"' /etc/opencode-devbox/build-manifest.json"
|
|
if [ "$VARIANT" = "omos" ]; then
|
|
run "manifest omos component is resolved (not null) in omos variant" \
|
|
"! grep -q '\"oh-my-opencode-slim\": null' /etc/opencode-devbox/build-manifest.json"
|
|
fi
|
|
# OCI labels live in the image config, not the container fs — inspect them
|
|
# from the host docker rather than via `docker run`.
|
|
LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.opencode-devbox.opencode-version" }}' "$IMAGE" 2>/dev/null || true)
|
|
if [ -n "$LBL" ] && [ "$LBL" != "<no value>" ]; then
|
|
pass "OCI label se.jordbo.opencode-devbox.opencode-version=$LBL"
|
|
else
|
|
fail "OCI label se.jordbo.opencode-devbox.opencode-version missing or empty"
|
|
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
|
|
|
|
# Proposed-config side-channel: when a config already exists, a NEWER default
|
|
# config is surfaced as a NON-loaded opencode.jsonc.proposed (write-on-diff,
|
|
# removed once the live config matches). The live config is never touched.
|
|
label="generate-config writes .proposed only when config differs"
|
|
if docker run --rm \
|
|
-e OPENCODE_PROVIDER=anthropic \
|
|
-e HOME=/tmp/home \
|
|
--entrypoint="" \
|
|
"$IMAGE" sh -c '
|
|
set -e
|
|
d=/tmp/home/.config/opencode
|
|
mkdir -p "$d"
|
|
gc=/usr/local/lib/opencode-devbox/generate-config.py
|
|
# (a) differing existing config → proposed written, live NOT clobbered
|
|
printf "{\n \"model\": \"old/model\"\n}\n" > "$d/opencode.jsonc"
|
|
python3 "$gc" 2>/dev/null
|
|
test -f "$d/opencode.jsonc.proposed"
|
|
grep -q "old/model" "$d/opencode.jsonc"
|
|
# (b) live matches defaults + stale proposed present → proposed removed
|
|
rm -f "$d/opencode.jsonc" "$d/opencode.jsonc.proposed"
|
|
python3 "$gc" 2>/dev/null
|
|
cp "$d/opencode.jsonc" "$d/opencode.jsonc.proposed"
|
|
python3 "$gc" 2>/dev/null
|
|
test ! -f "$d/opencode.jsonc.proposed"
|
|
echo ok
|
|
' 2>/dev/null | grep -q ok; then
|
|
pass "$label"
|
|
else
|
|
fail "$label: proposed-config behaviour incorrect"
|
|
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 2600 MB, omos 3300 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.
|
|
# 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.
|
|
# v1.16.2: all thresholds bumped +150 MB preemptively ahead of the combined
|
|
# opencode 1.15.13->1.16.2 (minor) + pi 0.78.1->0.79.0 (minor) bump. Both
|
|
# base (2506/2600) and omos (3206/3300) were sitting on ~94 MB headroom and
|
|
# a minor opencode bump has tripped them before (v1.15.0 omos). Restoring
|
|
# ~250 MB headroom avoids a partial-publish + letter-suffix recovery cycle.
|
|
# CI's smoke size print + resolved-versions table records the actual landed
|
|
# sizes; tighten later if they come in low.
|
|
# v2.6.0: bumped +200 MB (2750->2950 base, 3450->3650 omos). The pi-devbox
|
|
# CLI-parity additions (pandoc ~150 MB + graphviz ~40 MB + tealdeer ~5 MB, all
|
|
# on the base layer) consumed almost all of the ~250 MB headroom v1.16.2 had
|
|
# restored — v2.6.0 passed 2750/3450 but with a slim margin. Restoring ~250 MB
|
|
# headroom keeps the guardrail catching *runaway* growth (accidental texlive/
|
|
# chrome bake-in) rather than tripping on routine apt drift or a minor opencode
|
|
# bump. smoke still prints the actual landed size each run; tighten if low.
|
|
# v2.9.0: bumped +650 MB (2950->3600 base, 3650->4300 omos) for agent-browser +
|
|
# Playwright Chromium on the BASE layer (~625 MB after dropping the redundant
|
|
# chromium_headless_shell build). Sized to keep roughly the same ~250 MB of
|
|
# headroom the previous thresholds had, so the guardrail still catches *runaway*
|
|
# growth (an accidental texlive or a second browser) rather than tripping on
|
|
# routine apt drift. NOTE: do NOT copy pi-devbox's threshold number across — it
|
|
# sums `docker history` while this script uses `docker image inspect .Size`, so
|
|
# the two are not directly comparable. smoke prints the actual landed size every
|
|
# run; tighten these if they come in low.
|
|
THRESHOLD=3600
|
|
[ "$VARIANT" = "omos" ] && THRESHOLD=4300
|
|
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 ==="
|