Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions internal/pods/pods.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions pods/Containerfile
Original file line number Diff line number Diff line change
@@ -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"]
18 changes: 18 additions & 0 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:-}}"
Expand Down
Loading