Files
ai-agent/docker-entrypoint.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

68 lines
3.4 KiB
Bash

#!/bin/sh
# Container entrypoint: optionally start the *in-image runner*, then the backend.
#
# GOAL.md's first gap is "fold the host sidecar into the container": without a
# runner the image is a read-only viewer, because launching `claude -p` needs a
# process that can reach the CLI. The Claude Code CLI is a self-contained native
# binary, so the image can simply carry it — and the very same `sidecar/sidecar.py`
# that runs on the homelab host runs here, on the container's loopback.
#
# RUNNER_IN_CONTAINER=1 → start sidecar/sidecar.py on 127.0.0.1:$RUNNER_PORT
# and point the backend's SIDECAR_URL at it.
# unset (the homelab) → nothing changes: the backend proxies to the host
# sidecar over host.docker.internal, as before.
#
# The runner needs a writable HOME for the CLI's config/credentials, and the
# transcripts it writes there are picked up as a live source (RUNNER_TRANSCRIPTS_DIR
# → backend/main.py SOURCE_DIRS), so sessions spawned in-container stream into the
# viewer exactly like host-spawned ones.
#
# Auth for the CLI is whatever Claude Code itself accepts: ANTHROPIC_API_KEY (the
# key the token counter already uses), or an existing Claude home mounted at
# RUNNER_HOME (its OAuth credentials come along with it).
set -e
if [ "${RUNNER_IN_CONTAINER:-0}" = "1" ]; then
RUNNER_HOME="${RUNNER_HOME:-/data/home}"
RUNNER_PORT="${RUNNER_PORT:-8790}"
# Loopback-only, but sidecar.py refuses to run without a token — mint an
# ephemeral one when the operator didn't supply it, and hand it to both sides.
if [ -z "${SIDECAR_TOKEN:-}" ]; then
SIDECAR_TOKEN="$(python -c 'import secrets; print(secrets.token_hex(16))')"
fi
# The in-container runner is authoritative when it's on: the backend talks to
# it over loopback, not to any host sidecar.
SIDECAR_URL="http://127.0.0.1:${RUNNER_PORT}"
# Where the CLI writes its transcripts, and therefore what the backend watches.
RUNNER_TRANSCRIPTS_DIR="${RUNNER_TRANSCRIPTS_DIR:-${RUNNER_HOME}/.claude/projects}"
# Sessions run against the mounted repo by default.
SIDECAR_CWD="${SIDECAR_CWD:-${WORKSPACE:-/workspace}}"
SIDECAR_LOG_DIR="${SIDECAR_LOG_DIR:-/data/runner-logs}"
# Remote control pairs a run with the Claude app and needs an interactive
# login; a container that authenticates with an API key can't do that, so it's
# off unless the operator (with a mounted, logged-in Claude home) asks for it.
SIDECAR_REMOTE_CONTROL="${SIDECAR_REMOTE_CONTROL:-0}"
export SIDECAR_TOKEN SIDECAR_URL RUNNER_TRANSCRIPTS_DIR SIDECAR_CWD \
SIDECAR_LOG_DIR SIDECAR_REMOTE_CONTROL
mkdir -p "$RUNNER_TRANSCRIPTS_DIR" "$SIDECAR_LOG_DIR"
echo "[entrypoint] in-container runner: $(claude --version 2>/dev/null || echo 'claude MISSING')" \
"on 127.0.0.1:${RUNNER_PORT} (home=${RUNNER_HOME}, cwd=${SIDECAR_CWD})"
# Supervised: if the runner dies (an OOM, a bad CLI upgrade) it comes back
# rather than leaving the viewer silently unable to spawn. The backend's own
# process stays PID 1, so the container's health is still the API's health.
(
while true; do
HOME="$RUNNER_HOME" python -m uvicorn sidecar:app \
--app-dir /app/sidecar --host 127.0.0.1 --port "$RUNNER_PORT" \
|| echo "[entrypoint] runner exited ($?), restarting in 2s"
sleep 2
done
) &
fi
exec python -m uvicorn main:app --host 0.0.0.0 --port 8080