f09a4f382a
Validate / base-change-warning (push) Successful in 22s
Validate / docs-check (push) Successful in 44s
Validate / validate-base (push) Successful in 3m27s
Validate / validate-omos (push) Successful in 7m3s
Validate / validate-with-pi (push) Failing after 4m33s
Validate / validate-omos-with-pi (push) Failing after 8m29s
Item A — LAN access (base image): - New rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh, invoked non-fatally from entrypoint-user.sh. On VM-backed hosts (macOS OrbStack / Docker Desktop, detected via host.docker.internal) it generates a writable ~/.ssh-local/config that uses the host as an SSH jump to reach LAN peers; no-op on native Linux. Ships the mechanism (generic 'host' jump alias), not policy (targets stay in the user's bind-mounted ~/.ssh/config). - New env knobs: DEVBOX_LAN_ACCESS (auto|jump|off), HOST_SSH_USER, DEVBOX_HOST_ALIAS. dssh/dscp aliases in .bash_aliases (guarded). Item B — pi-fork (fork) + pi-observational-memory (recall) in pi variants: - Dockerfile.variant clones both elpapi42 repos to /opt and runs npm install there at build time (local-path 'pi install' does not npm-install, so deps must be present to load). New args PI_FORK_REPO/REF, PI_OBSMEM_REPO/REF. - entrypoint-user.sh registers them at runtime via 'pi install /opt/<pkg>' (instant, in-place, idempotent; tools bind on next pi start). - CI resolve-versions resolves each repo's master HEAD to a commit SHA and passes PI_FORK_REF/PI_OBSMEM_REF — same cache-hit guard as PI_VERSION. - smoke-test asserts /opt clones + node_modules + settings.json registration; size thresholds bumped (with-pi 2700->2900, omos-with-pi 3700->3900). Versions unchanged (opencode 1.15.13, pi 0.78.0 — both still latest). Docs: README LAN section + env table, .env.example, AGENTS.md, CHANGELOG. Plan recorded in docs/plan-lan-access-and-pi-extensions.md.
382 lines
16 KiB
Bash
Executable File
382 lines
16 KiB
Bash
Executable File
#!/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 <image> [--variant base|omos|with-pi|omos-with-pi]
|
|
#
|
|
# 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|with-pi|omos-with-pi]" >&2
|
|
exit 2
|
|
fi
|
|
|
|
FAILED=0
|
|
pass() { echo " ✓ $1"; }
|
|
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
|
|
|
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
|
|
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
|
|
}
|
|
|
|
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/<pkg>` (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 "pi-fork registered in settings.json (fork tool)" \
|
|
'grep -q pi-fork $HOME/.pi/agent/settings.json && echo ok'
|
|
exec_test "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.
|
|
# omos variant to ~3.1 GB. Functional smoke checks all pass; this is a
|
|
# guardrail, not a performance limit.
|
|
THRESHOLD=2500
|
|
[ "$VARIANT" = "omos" ] && THRESHOLD=3300
|
|
[ "$VARIANT" = "with-pi" ] && THRESHOLD=2900
|
|
[ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=3900
|
|
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 ==="
|