Files
ai-agent/scripts/standalone-smoke.sh
Gabriel Vidal 27b3caa0f9 feat(ai-agent): fold the runner into the container — standalone spawns sessions
GOAL.md's first gap: without a runner a standalone image is a read-only
viewer, because launching `claude -p` needed a host-side process.

The Claude Code CLI is a self-contained native binary, so the image can just
carry it — and the *same* sidecar/sidecar.py the homelab runs on its host runs
in-container against it. With RUNNER_IN_CONTAINER=1 (the standalone default)
docker-entrypoint.sh starts the runner on the loopback, mints a SIDECAR_TOKEN
if none was given, overrides SIDECAR_URL to point at it, and gives the CLI a
writable $HOME on the data volume. The transcripts it writes there become a
third live source (RUNNER_TRANSCRIPTS_DIR → SOURCE_DIRS), so an in-container
session streams into the viewer like any other.

Verified end to end: a standalone container (workspace + data, no homelab, no
host sidecar) spawns a session, the CLI runs it, and the turn renders in the
conversation list with its model tag. The CLI authenticates from
ANTHROPIC_API_KEY or a Claude home mounted at RUNNER_HOME.

The homelab is unchanged: it leaves the flag off and keeps its host sidecar,
which is what lets a run use the host's own hooks, skills and credentials.
standalone-smoke.sh now also asserts the CLI is on PATH and the runner is
healthy — the packaging property that would otherwise regress silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:08:54 +02:00

143 lines
5.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# Prove the image still runs standalone: no homelab, no sidecar, no mounts beyond
# a workspace and a data dir. Boots the image against a throwaway workspace and
# asserts every catalog endpoint answers 200 and the PWA shell serves.
#
# This guards a GOAL.md property ("replace homelab-specific mounts/config with a
# generic first-run setup" / "one-liner docker run"): the homelab compose mounts
# ~8 host paths, and it is easy to add code that assumes one of them exists. A
# missing catalog is a valid state — an empty list, never a 500.
#
# scripts/standalone-smoke.sh # smoke the current homelab-ai-agent image
# scripts/standalone-smoke.sh --build # build the image first, then smoke it
# AI_AGENT_SMOKE_IMAGE=foo:bar scripts/standalone-smoke.sh
#
# Exit 0 = the standalone path is intact. Exit 1 = it regressed (details printed).
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
IMAGE="${AI_AGENT_SMOKE_IMAGE:-homelab-ai-agent:latest}"
NAME="ai-agent-smoke-$$"
WORKDIR="$(mktemp -d)"
cleanup() {
docker rm -f "$NAME" >/dev/null 2>&1 || true
rm -rf "$WORKDIR"
}
trap cleanup EXIT
if [[ "${1:-}" == "--build" ]]; then
echo "==> building $IMAGE"
# network: host so the frontend stage reaches the host-bound private registry.
docker build --network host -t "$IMAGE" . >/dev/null
fi
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
echo "!! image $IMAGE not found — run with --build, or set AI_AGENT_SMOKE_IMAGE" >&2
exit 1
fi
# The whole point: a bare workspace. No projects/, no templates/, no .claude/, no
# services/, no transcripts, no memories, no screenshots, no sidecar.
mkdir -p "$WORKDIR/workspace" "$WORKDIR/data"
printf '# Smoke workspace\n\nA repo with nothing but this file.\n' \
> "$WORKDIR/workspace/CLAUDE.md"
echo "==> booting $IMAGE standalone (workspace + data only)"
# No published port: bind-mount nothing else and probe from inside, so the smoke
# test can't collide with whatever already owns a port on the host.
# RUNNER_IN_CONTAINER=1 is the standalone shape (docker-compose.standalone.yml):
# the bundled Claude Code CLI + sidecar.py come up on the container's loopback.
# No API key is passed, so a *spawn* would fail on auth — what's asserted here is
# that the runner is present and healthy, which is the packaging property that
# regresses silently.
docker run -d --name "$NAME" \
-u "$(id -u):$(id -g)" \
-e RUNNER_IN_CONTAINER=1 \
-v "$WORKDIR/workspace:/workspace" \
-v "$WORKDIR/data:/data" \
"$IMAGE" >/dev/null
# The image has python but no curl; probe over the loopback inside the container
# (127.0.0.1 is a trusted caller, so the API gate lets it through).
probe() {
docker exec "$NAME" python -c "
import sys, urllib.request
try:
r = urllib.request.urlopen('http://127.0.0.1:8080$1', timeout=20)
body = r.read(200).decode('utf-8', 'replace')
print(f'{r.status} {body[:110]}')
sys.exit(0 if r.status == 200 else 1)
except urllib.error.HTTPError as e:
print(f'{e.code} {e.read(200).decode(\"utf-8\", \"replace\")[:110]}')
sys.exit(1)
except Exception as e:
print(f'ERR {e}')
sys.exit(1)
" 2>&1
}
# Wait for uvicorn to answer at all before judging anything.
for i in $(seq 1 30); do
probe /api/health >/dev/null 2>&1 && break
if [[ $i == 30 ]]; then
echo "!! never became healthy; container logs:" >&2
docker logs "$NAME" 2>&1 | tail -30 >&2
exit 1
fi
sleep 1
done
ENDPOINTS=(
/api/health
/api/bundle
/api/conversations
/api/projects
/api/services
/api/memories
/api/templates
/api/plans
/api/cron
/api/models
/ # the PWA shell
)
failed=0
for ep in "${ENDPOINTS[@]}"; do
out="$(probe "$ep")" && status=ok || status=FAIL
printf ' %-6s %-22s %s\n' "$status" "$ep" "$out"
[[ $status == ok ]] || failed=1
done
# The in-image runner: the CLI must be on PATH and its sidecar must answer on the
# loopback. Without both, the standalone image silently degrades back to a
# read-only viewer — which is exactly the GOAL gap this closes.
if out="$(docker exec "$NAME" claude --version 2>&1)"; then
printf ' %-6s %-22s %s\n' ok 'claude CLI' "$out"
else
printf ' %-6s %-22s %s\n' FAIL 'claude CLI' "$out"; failed=1
fi
if out="$(docker exec "$NAME" python -c "
import urllib.request
print(urllib.request.urlopen('http://127.0.0.1:8790/health', timeout=10).read().decode()[:110])" 2>&1)"; then
printf ' %-6s %-22s %s\n' ok 'runner /health' "$out"
else
printf ' %-6s %-22s %s\n' FAIL 'runner /health' "$out"; failed=1
fi
# A crash inside a background thread (indexer/watcher) won't fail a request, so
# check the log too — those are exactly the paths that assume a homelab mount.
if docker logs "$NAME" 2>&1 | grep -qiE 'traceback|unhandled|FileNotFoundError'; then
echo "!! tracebacks in the container log:" >&2
docker logs "$NAME" 2>&1 | grep -iE -A4 'traceback|FileNotFoundError' | head -30 >&2
failed=1
fi
if [[ $failed == 0 ]]; then
echo "==> standalone OK: every catalog degrades cleanly with no homelab mounts"
else
echo "==> standalone REGRESSED (see above)" >&2
fi
exit $failed