diff --git a/internal/pods/pods.go b/internal/pods/pods.go index b93b6ec..3635575 100644 --- a/internal/pods/pods.go +++ b/internal/pods/pods.go @@ -87,6 +87,30 @@ func (m *Manager) hasMount(name, dest string) bool { return false } +// hasImage reports whether the named container is running the given image. +// Used to roll out a new pod image: a mismatch triggers an idle recreate so +// members pick up added tooling without losing their home volume. A blank or +// unresolvable image name is treated as a match (never heal on uncertainty). +func (m *Manager) hasImage(name, image string) bool { + if image == "" { + return true + } + out, err := exec.Command(m.engine, "container", "inspect", "-f", "{{.ImageName}}", name).Output() + if err != nil { + return true + } + got := strings.TrimSpace(string(out)) + // Normalize: inspect may report "localhost/agentbbs-pod:latest" while m.image + // is the same; also tolerate the docker.io/library/ prefix podman adds. + norm := func(s string) string { + s = strings.TrimPrefix(s, "docker.io/library/") + s = strings.TrimPrefix(s, "docker.io/") + s = strings.TrimPrefix(s, "localhost/") + return s + } + return norm(got) == norm(image) +} + // Engine reports the active container engine. func (m *Manager) Engine() string { return m.engine } @@ -131,8 +155,13 @@ func (m *Manager) ensure(user string) (string, error) { m.mu.Lock() idle := m.attached[name] == 0 m.mu.Unlock() - if pubSpec != "" && idle && !m.hasMount(name, "/home/dev/public_html") { - _ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate with the bind + // Recreate an idle pod when it's missing the public_html bind OR is + // running an out-of-date image (e.g. a new pod image with added tooling). + // The home volume persists across rm, so member data is kept; a busy pod + // heals on its next idle attach instead. + needsHeal := idle && ((pubSpec != "" && !m.hasMount(name, "/home/dev/public_html")) || !m.hasImage(name, m.image)) + if needsHeal { + _ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate } else { _ = exec.Command(m.engine, "start", name).Run() // no-op if running return name, nil diff --git a/pods/Containerfile b/pods/Containerfile new file mode 100644 index 0000000..67a7120 --- /dev/null +++ b/pods/Containerfile @@ -0,0 +1,45 @@ +# AgentBBS member pod image. Built on the host by setup.sh (rootless podman), +# tagged localhost/agentbbs-pod:latest, and used for every member pod via +# AGENTBBS_POD_IMAGE. Members get a full shell here (HOME=/home/dev, persisted +# in a named volume; ~/public_html is bind-mounted to their website). +# +# Beyond a base Ubuntu it ships: +# - git + openssh-client → push to git.profullstack.com (SSH-key auth) +# - Node.js (LTS) → runtime for the AI coding CLIs +# - Claude Code + Codex CLIs → `claude` and `codex`, BYO API key per user +# +# BYO key: nothing here carries credentials. A member exports their own +# ANTHROPIC_API_KEY / OPENAI_API_KEY (or runs the tools' login flow); the keys +# live in their persisted home, never in the image. +FROM docker.io/library/ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg \ + git openssh-client \ + vim nano less ripgrep jq \ + && install -d -m 0755 /usr/share/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g @anthropic-ai/claude-code @openai/codex \ + && npm cache clean --force \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# A login hint so members know the AI tools are present and BYO-key. +RUN printf '%s\n' \ + 'AgentBBS pod — coding tools ready:' \ + ' claude (Claude Code) — export ANTHROPIC_API_KEY=... or run: claude' \ + ' codex (OpenAI Codex) — export OPENAI_API_KEY=... or run: codex' \ + ' git push → git@git.profullstack.com (your BBS SSH key is your git key)' \ + > /etc/motd \ + && printf '[ -n "$PS1" ] && [ -r /etc/motd ] && cat /etc/motd\n' \ + > /etc/profile.d/10-agentbbs-motd.sh + +CMD ["sleep", "infinity"] diff --git a/setup.sh b/setup.sh index 9fbd606..00991be 100755 --- a/setup.sh +++ b/setup.sh @@ -216,6 +216,21 @@ fi sudo -u "$SVC_USER" XDG_RUNTIME_DIR="/run/user/$SVC_UID" \ podman pull -q "$POD_IMAGE" >/dev/null 2>&1 || warn "could not pre-pull $POD_IMAGE (pods will pull on first use)" +# Build the member pod image (FROM $POD_IMAGE): adds git, openssh-client, Node, +# and the Claude Code + Codex CLIs so members can code in their pod (BYO API +# key). podman layer-caches, so an unchanged Containerfile rebuilds cheaply. On +# failure we keep the base image rather than break pod launches. +if [ -f "$SRC_DIR/pods/Containerfile" ]; then + log "building member pod image (localhost/agentbbs-pod:latest)" + if sudo -u "$SVC_USER" XDG_RUNTIME_DIR="/run/user/$SVC_UID" \ + podman build -t localhost/agentbbs-pod:latest \ + -f "$SRC_DIR/pods/Containerfile" "$SRC_DIR/pods" >/dev/null 2>&1; then + POD_IMAGE="localhost/agentbbs-pod:latest" + else + warn "pod image build failed — keeping $POD_IMAGE (run: podman build -f $SRC_DIR/pods/Containerfile $SRC_DIR/pods)" + fi +fi + # ---- 6. environment file --------------------------------------------------- ENV_DIR=/etc/agentbbs install -d -m 0750 "$ENV_DIR" @@ -332,6 +347,9 @@ upsert_env() { # KEY VALUE — skips when VALUE is empty chmod 0640 "$file" } # CoinPay: API key (read by the coinpay CLI) + merchant/business id. +# Point existing installs at the freshly built member pod image (fresh installs +# get it from the env-file template below). +upsert_env AGENTBBS_POD_IMAGE "$POD_IMAGE" upsert_env COINPAY_API_KEY "${COINPAY_API_KEY:-}" upsert_env AGENTBBS_COINPAY_MERCHANT_ID "${COINPAY_MERCHANT_ID:-${AGENTBBS_COINPAY_MERCHANT_ID:-}}" upsert_env COINPAY_BUSINESS_ID "${COINPAY_MERCHANT_ID:-${AGENTBBS_COINPAY_MERCHANT_ID:-}}"