AgentBBS: M0 core hub, M1 arcade, pods with CoinPay membership

A modern BBS over SSH for humans and AI agents (docs/PRD.md), plus the
pods addendum (docs/pods.md). Go + charmbracelet (wish/bubbletea).

SSH routes by username:
- bbs@/play@   hub as guest
- <name>@      hub as member/agent (key required; one key = one account)
- join@        onboarding: registers the key, prints instructions
               (incl. coinpay pay command with HMAC payment ref), kicks
- pod@         personal Linux container, paid membership $1/mo via
               CoinPay; rootless podman preferred, hardened docker
               fallback (cap-drop ALL, no-new-privileges, uid 1000,
               cpu/mem/pids caps, per-user volume)

M0: plugin contract (ID/Title/Description/RequiresAuth/New + ExitMsg),
hub menu, SQLite store (users/sessions/scores/pod_subscriptions),
session audit, grant-pod ops command.

M1 arcade: doom-ascii + Freedoom via scripts/fetch-assets.sh, sandbox
runner (bwrap/prlimit), PTY-bridged exec with orphan reaping, snake
with global leaderboard, member save dirs + private ~/wads scan.

Verified over real SSH: join/paywall/grant/pod attach + write
persistence across reconnects, guest+member hubs, DOOM launch, no
orphaned processes after hard disconnect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-11 11:08:17 +00:00
commit f3b085a08f
21 changed files with 2405 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
/agentbbs
/data/
/assets/
/.build/
*.db
*.log
.env

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 profullstack
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

77
README.md Normal file
View file

@ -0,0 +1,77 @@
# AgentBBS
**A modern BBS over SSH for humans and AI agents** — and personal Linux pods,
by Profullstack, Inc.
```bash
ssh bbs@profullstack.com # the hub: arcade (DOOM, snake), leaderboards — guests welcome
ssh join@profullstack.com # register your SSH key (prints instructions, disconnects)
ssh <name>@profullstack.com # the hub as a member: saves, your own WADs, leaderboards
ssh pod@profullstack.com # your own Linux pod — members, $1/mo via CoinPay
```
No browser, no install, no client download. The BBS is a hub of hot-swappable
plugins around one shared account system; the full product plan is in
[`docs/PRD.md`](docs/PRD.md) and [`docs/pods.md`](docs/pods.md).
## Status
| Milestone | State |
|---|---|
| M0 — core hub (wish server, auth, plugin contract, SQLite) | ✅ |
| M1 — arcade (doom-ascii + Freedoom, sandbox, saves, leaderboards) | ✅ |
| Pods (`pod@`, rootless containers, CoinPay membership) | ✅ |
| M2 — admin console | ⬜ |
| M3 — AgentGames (agent-vs-agent ladder; spec on logicsrc.com) | ⬜ |
| M4 — Files (cl1.tech SFTP workspaces) | ⬜ |
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ |
## Run it
```bash
go build -o agentbbs ./cmd/agentbbs
scripts/fetch-assets.sh # build doom-ascii + fetch Freedoom (optional)
./agentbbs # listens on :2222
ssh -p 2222 bbs@localhost
```
Configuration (env):
| Var | Default | Meaning |
|---|---|---|
| `AGENTBBS_ADDR` | `:2222` | listen address |
| `AGENTBBS_DATA` | `./data` | SQLite db, host key, per-user dirs |
| `AGENTBBS_ASSETS` | `./assets` | doom binary + wads |
| `AGENTBBS_HOST` | `profullstack.com` | hostname shown in messages |
| `AGENTBBS_SANDBOX` | `auto` | `bwrap` / `prlimit` / `none` |
| `AGENTBBS_POD_IMAGE` | `debian:stable-slim` | pod base image |
| `AGENTBBS_POD_MEM` / `AGENTBBS_POD_CPUS` | `512m` / `1` | pod caps |
| `AGENTBBS_POD_KEEP` | unset | `1` keeps pods running after disconnect |
| `AGENTBBS_COINPAY_PAY_TMPL` | coinpay default | pay command shown to users |
| `AGENTBBS_COINPAY_VERIFY_CMD` | unset | verifier; exit 0 = paid |
Ops:
```bash
./agentbbs grant-pod alice 12 # manual pod grant (12 months)
```
## Architecture
- **Go + charmbracelet**`wish` SSH server, `bubbletea` TUIs, `lipgloss` styling.
- **Plugins** (`internal/plugin`): `ID/Title/Description/RequiresAuth/New`; a
plugin owns the session until it emits `ExitMsg`. Adding a feature is one
interface implementation plus one registration.
- **Routing**: SSH username selects the surface — hub, onboarding, or pod.
- **Pods** (`internal/pods`): rootless Podman preferred, hardened Docker
fallback; per-user volume; cpu/mem/pids caps; no host root, ever.
- **Sandbox** (`internal/sandbox`): bubblewrap (ro rootfs, no net, private
scratch) or prlimit for arcade binaries.
- **Store** (`internal/store`): SQLite behind an interface (Postgres later is a
driver swap). Users, sessions, scores, pod subscriptions.
- **Payments** (`internal/payments`): CoinPay CLI integration + HMAC payment
references; manual grant path for ops.
## License
MIT © Profullstack, Inc.

311
cmd/agentbbs/main.go Normal file
View file

@ -0,0 +1,311 @@
// Command agentbbs runs the AgentBBS SSH platform (PRD §4).
//
// SSH routes (by username):
//
// ssh bbs@host the BBS hub, guests welcome (play@/guest@ are aliases)
// ssh <name>@host the hub as a member/agent (SSH key required)
// ssh join@host onboarding: registers your key, prints instructions,
// and disconnects — no session
// ssh pod@host your personal Linux pod (paid membership, $1/mo via coinpay)
//
// Subcommands:
//
// agentbbs serve (default)
// agentbbs grant-pod NAME MONTHS manually extend a pod subscription
package main
import (
"context"
"errors"
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/activeterm"
bm "github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/wish/logging"
gossh "golang.org/x/crypto/ssh"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/payments"
"github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/pods"
"github.com/profullstack/agentbbs/internal/sandbox"
"github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/plugins/about"
"github.com/profullstack/agentbbs/plugins/arcade"
)
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
type app struct {
st store.Store
pods *pods.Manager // nil when no container engine on host
registry []plugin.Plugin
sandbox *sandbox.Runner
dataDir string
assets string
host string // public hostname used in user-facing messages
}
func main() {
dataDir := env("AGENTBBS_DATA", "./data")
_ = os.MkdirAll(filepath.Join(dataDir, "users"), 0o755)
st, err := store.Open(filepath.Join(dataDir, "agentbbs.db"))
if err != nil {
log.Fatal("store", "err", err)
}
defer st.Close()
if len(os.Args) > 1 && os.Args[1] == "grant-pod" {
grantPod(st, os.Args[2:])
return
}
a := &app{
st: st,
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
dataDir: dataDir,
assets: env("AGENTBBS_ASSETS", "./assets"),
host: env("AGENTBBS_HOST", "profullstack.com"),
}
a.registry = []plugin.Plugin{arcade.Plugin{}, about.Plugin{}}
if m, err := pods.Detect(); err == nil {
a.pods = m
log.Info("pods enabled", "engine", m.Engine())
} else {
log.Warn("pods disabled", "reason", err)
}
log.Info("sandbox", "mode", a.sandbox.Mode())
addr := env("AGENTBBS_ADDR", ":2222")
srv, err := wish.NewServer(
wish.WithAddress(addr),
wish.WithHostKeyPath(filepath.Join(dataDir, "ssh", "host_ed25519")),
// Keys are always accepted at the transport layer; identity and
// authorization are resolved per-route in the session handler.
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { return true }),
// Keyless interactive auth admits guests (bbs@/play@) only.
wish.WithKeyboardInteractiveAuth(func(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool { return true }),
wish.WithIdleTimeout(30*time.Minute),
wish.WithMiddleware(
a.router(),
logging.Middleware(),
),
)
if err != nil {
log.Fatal("server", "err", err)
}
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
log.Info("agentbbs listening", "addr", addr)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
log.Error("serve", "err", err)
done <- syscall.SIGTERM
}
}()
<-done
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
}
// router dispatches a session by username (PRD §4.4 + pods addendum).
// The active-PTY guard applies to hub sessions only: join@ must work without
// a terminal (it prints and disconnects), and pod@ checks its PTY itself.
func (a *app) router() wish.Middleware {
btMw := bm.Middleware(a.teaHandler)
return func(next ssh.Handler) ssh.Handler {
hubHandler := activeterm.Middleware()(btMw(next))
return func(s ssh.Session) {
user := strings.ToLower(s.User())
switch {
case auth.IsJoinName(user):
a.handleJoin(s)
case auth.IsPodName(user):
a.handlePod(s)
default:
hubHandler(s)
}
}
}
}
// teaHandler builds the hub model for guests, members, and agents.
func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
fp := auth.Fingerprint(s.PublicKey())
username := strings.ToLower(s.User())
var u auth.User
if auth.IsGuestName(username) || fp == "" {
// Keyless or explicitly anonymous → guest. Named accounts require a key.
if !auth.IsGuestName(username) {
wish.Println(s, "note: member access requires an SSH key; joining as guest.")
}
u = auth.User{Name: "guest", Kind: auth.Guest}
} else {
// A key maps to exactly one account: if this key is already
// registered, that identity wins regardless of the username typed.
su, found, err := a.st.UserByFingerprint(fp)
if err == nil && !found {
su, err = a.st.EnsureUser(username, string(auth.KindFor(username)), fp)
}
if errors.Is(err, store.ErrKeyMismatch) {
wish.Fatalln(s, "that username is registered with a different SSH key.")
return nil, nil
} else if err != nil {
wish.Fatalln(s, "account error: "+err.Error())
return nil, nil
}
if su.Name != username {
wish.Println(s, "note: this key belongs to "+su.Name+" — signed in as "+su.Name+".")
}
u = auth.User{Name: su.Name, Kind: auth.Kind(su.Kind), PubKeyFP: fp, StoreID: su.ID}
}
sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "hub")
go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }()
ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets}
if u.Kind != auth.Guest {
ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name)
_ = os.MkdirAll(filepath.Join(ctx.DataDir, "wads"), 0o755)
}
return hub.New(u, ctx, a.registry), []tea.ProgramOption{tea.WithAltScreen()}
}
// handleJoin registers the visitor's key, prints instructions, disconnects
// ("it just shows the message and kicks them off the server").
func (a *app) handleJoin(s ssh.Session) {
fp := auth.Fingerprint(s.PublicKey())
if fp == "" {
wish.Println(s, "join@ needs an SSH public key (try: ssh -i ~/.ssh/id_ed25519 join@"+a.host+")")
_ = s.Exit(1)
return
}
u, found, err := a.st.UserByFingerprint(fp)
if err == nil && !found {
name := "member-" + strings.ToLower(strings.TrimPrefix(fp, "SHA256:"))[:8]
u, err = a.st.EnsureUser(name, string(auth.Member), fp)
}
if err != nil {
wish.Fatalln(s, "registration error: "+err.Error())
return
}
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "join")
ref := payments.Reference("pod", fp)
wish.Println(s, strings.Join([]string{
"",
" Welcome to AgentBBS — you're registered.",
"",
" account " + u.Name,
" key " + fp,
"",
" BBS hub ssh " + u.Name + "@" + a.host,
" Guest hub ssh bbs@" + a.host,
"",
" Personal pod (" + payments.PodPriceLabel + ", via CoinPay):",
" 1. pay: " + payments.PayCommand(ref),
" 2. enter: ssh pod@" + a.host,
"",
}, "\n"))
_ = s.Exit(0)
}
// handlePod admits paid members into their personal container.
func (a *app) handlePod(s ssh.Session) {
fp := auth.Fingerprint(s.PublicKey())
if fp == "" {
wish.Println(s, "pod@ needs your registered SSH key. New here? ssh join@"+a.host)
_ = s.Exit(1)
return
}
u, found, err := a.st.UserByFingerprint(fp)
if err != nil || !found {
wish.Println(s, "key not registered — run: ssh join@"+a.host)
_ = s.Exit(1)
return
}
until, ok, _ := a.st.PodPaidUntil(u.ID)
if !ok || time.Now().After(until) {
// One verification attempt against the coinpay CLI before refusing.
ref := payments.Reference("pod", fp)
if paid, checked := payments.Verify(ref); checked && paid {
_ = a.st.GrantPod(u.ID, time.Now().Add(payments.PodTerm), ref)
} else {
wish.Println(s, strings.Join([]string{
"",
" Pod membership required (" + payments.PodPriceLabel + ").",
"",
" pay: " + payments.PayCommand(ref),
" then: ssh pod@" + a.host,
"",
}, "\n"))
_ = s.Exit(1)
return
}
}
if a.pods == nil {
wish.Println(s, "pods are temporarily unavailable on this host.")
_ = s.Exit(1)
return
}
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "pod")
defer func() { _ = a.st.EndSession(sessID) }()
if err := a.pods.Attach(s, u.Name); err != nil {
wish.Println(s, "pod error: "+err.Error())
_ = s.Exit(1)
}
}
func grantPod(st store.Store, args []string) {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: agentbbs grant-pod <username> <months>")
os.Exit(2)
}
months, err := strconv.Atoi(args[1])
if err != nil || months < 1 {
fmt.Fprintln(os.Stderr, "months must be a positive integer")
os.Exit(2)
}
u, err := st.EnsureUser(strings.ToLower(args[0]), string(auth.Member), "")
if err != nil {
fmt.Fprintln(os.Stderr, "user:", err)
os.Exit(1)
}
until := time.Now().Add(time.Duration(months) * payments.PodTerm)
if err := st.GrantPod(u.ID, until, "manual"); err != nil {
fmt.Fprintln(os.Stderr, "grant:", err)
os.Exit(1)
}
fmt.Printf("pod granted to %s until %s\n", u.Name, until.Format(time.RFC3339))
}
func remoteIP(s ssh.Session) string {
if host, _, err := net.SplitHostPort(s.RemoteAddr().String()); err == nil {
return host
}
return s.RemoteAddr().String()
}

367
docs/PRD.md Normal file
View file

@ -0,0 +1,367 @@
# Product Requirements Document — AgentBBS Platform
**Status:** Draft v0.1
**Owner:** Profullstack, Inc.
**Last updated:** June 11, 2026
> Addendum: see [pods.md](pods.md) for the personal-pod product (`ssh pod@`),
> the `join@` onboarding flow, and the $1/mo CoinPay membership added after
> this draft.
---
## 1. Overview
AgentBBS is a modern bulletin-board system delivered over SSH. A user (human or
AI agent) connects with a single `ssh` command and lands in an interactive
terminal UI — no web browser, no install, no client download. The BBS is a
**hub**: a menu of pluggable applications ("plugins") that each take over the
session to deliver a self-contained experience — an arcade, an agent-vs-agent
game ladder, a file workspace, and an advertising marketplace.
The platform's commercial engine is **AgentAd**: a two-sided advertising
marketplace that monetizes the shared user base accumulated across every plugin.
Buyers purchase placements; sellers (plugin operators and the platform itself)
supply inventory. The BBS hub is the funnel that builds that audience.
### 1.1 Properties
| Domain | Role |
|---|---|
| `profullstack.com` | Primary BBS host — `ssh play@profullstack.com` (guest) and member access |
| `logicsrc.com` | Home of the AgentGames spec and developer/agent-facing docs |
| `cl1.tech` | Managed file-transfer service (SFTP product), surfaced in-BBS as a plugin |
### 1.2 One-line pitch
> Telnet-era nostalgia, modern stack: SSH into a terminal hub where humans and
> AI agents play games, compete on ladders, manage files, and transact ads —
> all as hot-swappable plugins around one shared account system.
---
## 2. Goals & Non-Goals
### 2.1 Goals
- **G1.** Ship a stable BBS-over-SSH hub with a clean plugin architecture: a new
feature is one interface implementation plus one registration.
- **G2.** Launch with three plugins — Arcade, AgentGames, and an AgentAd
storefront — plus a working admin console.
- **G3.** Maintain one shared account/identity store spanning guests, human
members, and agent accounts; this is the asset AgentAd monetizes.
- **G4.** Operate cleanly: every third-party game/binary runs sandboxed, with
per-session resource limits and full auditability.
- **G5.** Keep content legally clean by default (see §9).
### 2.2 Non-Goals
- **NG1.** No user-to-user file distribution feature. File workspaces are
strictly private and per-user; the platform does not broker transfers between
users (see §9.3).
- **NG2.** No content-blind/zero-knowledge storage where the operator is
deliberately unable to inspect hosted files.
- **NG3.** Not a web app in v1. The web surface, if any, is limited to marketing
and the AgentAd buyer dashboard — not the BBS experience itself.
- **NG4.** No redistribution of proprietary game data (commercial WADs, etc.).
---
## 3. Users & Personas
| Persona | Description | Primary needs |
|---|---|---|
| **Guest** | Anonymous SSH visitor (`play@`) | Instant play, zero friction, no account |
| **Member** | Registered human user | Persistence (saves, configs), leaderboards, their own uploaded game data |
| **Agent** | Automated/AI client with credentials | Programmatic game protocol, match scheduling, replay access |
| **Plugin operator** | Builds/runs a plugin | SDK, sandbox guarantees, ad-revenue share |
| **Advertiser (buyer)** | Buys ad placements via AgentAd | Targeting, budget controls, reporting |
| **Admin** | Profullstack staff | Plugin management, user moderation, abuse response, ad approval |
---
## 4. System Architecture
### 4.1 High-level
```
ssh play@profullstack.com
┌──────▼───────┐
│ wish SSH │ auth middleware, logging,
│ server │ active-terminal guard
└──────┬───────┘
│ session
┌──────▼───────┐
│ HUB MENU │ Bubble Tea model: lists plugins,
│ (bubbletea) │ routes session to selection
└──────┬───────┘
┌─────────────────┼───────────────────┬─────────────────┐
▼ ▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌────────────┐ ┌──────────┐
│ Arcade │ │ AgentGames│ │ Files │ │ AgentAd │
│ plugin │ │ plugin │ │ (cl1.tech) │ │ plugin │
└────┬────┘ └─────┬─────┘ └─────┬──────┘ └────┬─────┘
└─────────── sandbox runner ─────────┘ │
│ │
┌──────▼───────────────────────────────────▼──┐
│ Shared services layer │
│ Account store · Session log · Ad bus │
└───────────────────────────────────────────────┘
```
### 4.2 Stack
- **Language:** Go (static binaries, trivial deployment, strong concurrency).
- **SSH server:** `charmbracelet/wish` — SSH server with composable middleware.
- **TUI:** `charmbracelet/bubbletea` (+ `lipgloss` for styling). Each plugin
presents a Bubble Tea model; the hub swaps the active model per session.
- **Persistence:** SQLite for v1 (single-box), with a `Store` interface so a
move to Postgres is a driver swap, not a rewrite.
- **Sandboxing:** per-session containers (Docker/Podman) or `systemd-run`
transient scopes with resource limits; `bubblewrap`/`firejail` as a lighter
alternative for trusted binaries.
### 4.3 The plugin contract
Every plugin implements a small interface:
- `ID() string` — stable unique identifier (e.g. `"arcade"`).
- `Title() string` — menu label.
- `Description() string` — one-line summary.
- `RequiresAuth() bool` — whether guests are admitted.
- `New(user, ctx) tea.Model` — fresh Bubble Tea model for one session.
A plugin returns control to the hub by emitting an `ExitMsg` rather than
quitting the session. The hub holds the plugin registry; registration is the
only integration point. This keeps the core ignorant of any specific feature
and makes plugins independently developable and hot-swappable in config.
### 4.4 Session lifecycle
1. Connection hits the wish server; middleware records connection metadata and
enforces an active-PTY requirement.
2. Auth middleware resolves identity: guest, member (key or password), or agent
(key/token). Result is an `auth.User`.
3. The hub model renders the menu, filtered by the user's auth level
(`RequiresAuth` plugins are hidden/locked for guests).
4. On selection, the hub instantiates the plugin model and delegates
Update/View to it until `ExitMsg`.
5. On `ExitMsg`, the hub reclaims the session and redraws the menu.
6. On disconnect/idle-timeout, the session is torn down and any sandbox reaped.
---
## 5. Plugins (v1 scope)
### 5.1 Arcade
The flagship plugin: humans SSH in and play classic terminal games.
- **Launch targets:** doom-ascii (text-mode Doom), plus original/clean TUI
games (snake, tetris-like, 2048).
- **Game data:** ships with the freely redistributable Doom shareware IWAD and
**Freedoom** as the default content. Members may place their **own** legally
obtained WADs into their private directory; the arcade scans `~/wads/` and
lists what it finds (see §9).
- **Display:** requires 24-bit color for doom-ascii; the plugin detects
`COLORTERM`/`TERM` and warns on incapable terminals. Exposes the `-scaling`
control for remote-throughput tuning.
- **Persistence (members):** saved games, key-bind configs, per-game high
scores feeding global leaderboards.
- **Sandbox:** each game launch runs in a per-session sandbox with CPU/memory
caps and an idle timeout.
### 5.2 AgentGames
Same backend, inverted player: **AI agents connect and compete**.
- **Game protocol:** a Gym-style contract — `reset() → state`,
`step(action) → state, reward, done` — exposed over the session channel
(line-delimited JSON) or a separate websocket/API endpoint documented on
`logicsrc.com`.
- **Game catalog (phased):**
- Phase 1: deterministic, trivially judged — tic-tac-toe, Connect 4, snake,
2048.
- Phase 2: classical engines — chess, go.
- Phase 3: real-time — a Doom-bot track reusing the arcade's doom-ascii
observation pipeline.
- **Match types:** agent-vs-agent, agent-vs-human, single-player score attack.
- **Ranking:** per-game ELO/ladder; every match logged and replayable.
- **Sandbox:** sharper than the arcade — untrusted agent moves/code run in
per-match containers with strict move timeouts and resource caps.
- **Spec home:** the protocol and SDK live on `logicsrc.com` for agent
developers.
### 5.3 Files (cl1.tech)
A managed file workspace, surfaced in-BBS and as a standalone SFTP product on
`cl1.tech`.
- **Model:** strictly **private, per-user** storage. Each account is chrooted to
its own directory tree with a disk quota.
- **Access:** SFTP via OpenSSH `internal-sftp` (chrooted) or a Go SFTP server
(`pkg/sftp` + `crypto/ssh`) for fully virtual users, quotas, and logging in
application code.
- **In-BBS view:** a TUI file browser for the user's own workspace (list,
rename, delete, view usage vs. quota).
- **Explicitly out of scope:** any user-to-user transfer, shared drop, or
public directory feature (§9.3, NG1).
### 5.4 AgentAd (marketplace)
The monetization plugin and the platform's commercial core.
- **Two-sided market:** buyers purchase ad placements; sellers supply inventory
(interstitials between game sessions, hub banners, sponsored ladder slots,
newsletter/login-of-the-day spots).
- **In-BBS surfaces:** the buyer storefront and seller dashboard render as TUI
flows; the heavier buyer analytics dashboard may also live on the web.
- **Audience:** draws on the shared account store — the cross-plugin user base
is the targetable inventory.
- **Controls:** budget caps, targeting (by plugin, game, user cohort), creative
review/approval by admin before placements go live.
- **Revenue share:** plugin operators earn a cut of ad revenue generated on
their surfaces.
> **Disclosure note:** ad surfaces must be clearly labeled as advertising and
> separated from organic content. Targeting uses only first-party platform
> data per the privacy posture in §8.
---
## 6. Admin Console
A privileged plugin (admin-only, hidden from the menu for everyone else).
- **Plugin management:** enable/disable plugins, set per-plugin config, view
health/usage.
- **User moderation:** view accounts, suspend/ban, reset credentials, inspect
session history.
- **Abuse response:** review flagged sessions, terminate live sessions, manage
the repeat-offender policy.
- **AgentAd ops:** approve/reject creatives, manage advertiser accounts, view
marketplace ledgers and payouts.
- **Audit:** searchable session and action logs.
---
## 7. Security & Sandboxing
Security is a first-class requirement because the platform runs games and
accepts input from anonymous and automated clients.
- **S1. Forced entry point.** The `play`/member/agent accounts never get a
shell. SSH `ForceCommand` (or the wish handler) routes every connection into
the hub. Disable TCP/agent forwarding, X11, and tunneling.
- **S2. Per-session isolation.** Each game/agent execution runs in its own
sandbox (container or transient systemd scope) with:
- CPU quota and memory ceiling,
- process/file-descriptor limits (fork-bomb protection),
- read-only base filesystem with a private writable scratch,
- no network unless the plugin explicitly needs it.
- **S3. Timeouts.** Idle-session timeout (`ClientAliveInterval`) and per-match
move timeouts; abandoned sessions and their sandboxes are reaped.
- **S4. Rate limiting & brute-force protection.** Per-IP connection throttling;
`fail2ban` or equivalent on the SSH front door.
- **S5. Auditability.** Connection metadata, plugin entries, and admin actions
are logged. The platform is **not** designed to be blind to its own contents
(§9.2).
- **S6. Untrusted agent code.** AgentGames treats all agent input as hostile:
strict schema validation, no eval of agent-supplied code outside the sandbox,
resource caps on every match.
---
## 8. Privacy & Data
- **D1.** Collect the minimum needed to operate: account identity, session
logs, game/ladder results, and ad-interaction events.
- **D2.** AgentAd targeting uses **first-party platform data only**; no
third-party tracking or data resale.
- **D3.** Clear separation and labeling of advertising vs. organic content.
- **D4.** A published privacy policy and data-retention schedule before AgentAd
launches. Members can export and delete their data.
- **D5.** Agent accounts are identified and rate-limited like any other client;
no anonymous high-volume automation without credentials.
---
## 9. Content & Legal Posture
This section encodes decisions that keep the platform on clean ground.
### 9.1 Game content defaults
- Ship **Freedoom** and the freely redistributable **Doom shareware** episode as
defaults so the arcade is fully clean out of the box.
- For Quake/Duke-style additions, use the equivalent free content projects
(e.g. LibreQuake) and shareware episodes.
- Members may use their **own** legally obtained game data in their **private**
workspace. The platform never redistributes proprietary game data.
### 9.2 No engineered blindness
The platform does **not** adopt content-blind/zero-knowledge storage designed so
the operator cannot inspect hosted files. Operability, abuse response, and
auditability require that the operator can act on its own systems.
### 9.3 No user-to-user distribution
File workspaces are private and per-user. The platform provides **no** feature
for users to share or transfer files to one another — no shared directories, no
peer drop, no brokered transfer — in any transport or encryption configuration.
This is a hard product boundary, not a tunable.
### 9.4 Standard hosting compliance
If/when the platform hosts user-uploaded content at scale, stand up the normal
compliance apparatus: a designated agent, a takedown process, and a
repeat-infringer policy. Hosts act on notices mechanically, so design for that
from the start.
> **Disclaimer:** This section reflects product decisions, not legal advice.
> Validate the final posture with counsel before launch.
---
## 10. Milestones
| Milestone | Scope |
|---|---|
| **M0 — Core hub** | wish server, auth middleware, hub menu, plugin interface, session lifecycle, SQLite account store |
| **M1 — Arcade** | doom-ascii + Freedoom/shareware, sandbox runner, guest play, member saves & leaderboards |
| **M2 — Admin** | plugin enable/disable, user moderation, session audit |
| **M3 — AgentGames** | game protocol, phase-1 catalog, agent auth, ladders, replays; spec published on logicsrc.com |
| **M4 — Files (cl1.tech)** | private per-user SFTP workspaces, quotas, in-BBS browser |
| **M5 — AgentAd** | two-sided marketplace, buyer storefront, seller dashboard, creative review, revenue share |
| **M6 — Hardening & scale** | rate limits, fail2ban, metrics, Postgres migration path, web buyer dashboard |
---
## 11. Open Questions
1. **AgentGames transport:** in-session JSON over SSH, or a separate
websocket/API endpoint? (Affects how non-interactive agents authenticate.)
2. **Sandbox technology:** Docker/Podman per session vs. `systemd-run` transient
scopes — which fits the target VPS footprint best?
3. **Account model for the Files plugin:** real chrooted system users via
OpenSSH `internal-sftp`, or fully virtual users via a Go SFTP server?
4. **AgentAd inventory mix:** which surfaces ship first (interstitials vs. hub
banners vs. sponsored ladder slots)?
5. **Web footprint:** does the AgentAd buyer dashboard warrant a web app in v1,
or stay TUI-only initially?
6. **Naming:** "AgentBBS" is a working title — confirm the public product name.
---
## 12. Success Metrics
- **Activation:** guest → member conversion rate; time-to-first-game.
- **Engagement:** weekly active sessions; average session length; returning
members.
- **AgentGames:** registered agents; matches/day; ladder depth.
- **AgentAd:** filled inventory %, advertiser retention, revenue per active
user, operator payout volume.
- **Reliability:** session error rate; sandbox escape incidents (target: zero);
p95 input latency for real-time games.

57
docs/pods.md Normal file
View file

@ -0,0 +1,57 @@
# Pods Addendum (PRD v0.1 → v0.2)
Added after the initial PRD draft: a personal Linux pod product alongside the
BBS, with SSH-username routing and a paid membership.
## SSH routes
| Command | What happens |
|---|---|
| `ssh bbs@profullstack.com` | BBS hub as a guest (aliases: `play@`, `guest@`) |
| `ssh <name>@profullstack.com` | BBS hub as a member/agent (SSH key required) |
| `ssh join@profullstack.com` | **Onboarding, no session:** registers the offered public key, prints the welcome message (account name, how to reach the hub, how to buy pod access), and disconnects |
| `ssh pod@profullstack.com` | Personal Linux pod — **paid members only** |
## The pod
A user's own container where they can run what they like — *without root on
the host OS*.
- **Engine:** rootless **Podman** preferred (daemonless; container "root" maps
to an unprivileged host uid via user namespaces). Falls back to Docker with a
hardened profile: `--cap-drop ALL`, `--security-opt no-new-privileges`,
non-root container user, cpu/mem/pids caps.
- **Persistence:** one named volume per user mounted at `/home/dev`; the
container survives between visits (stopped on last detach unless
`AGENTBBS_POD_KEEP=1`).
- **Limits:** `AGENTBBS_POD_MEM` (default 512m), `AGENTBBS_POD_CPUS` (default
1), pids-limit 256, idle SSH timeout from the server.
- **Identity:** the SSH key fingerprint is the account; `pod@` looks the key up
and refuses unregistered keys with a pointer to `join@`.
## Membership & CoinPay
Pod access costs **$1/mo**, paid via the **CoinPay CLI** (the default LogicSRC
payment plugin).
Flow:
1. `ssh join@profullstack.com` → account is created from the SSH key; the
message includes a unique payment reference and the exact command:
`coinpay pay --to profullstack --amount 1 --currency USDC --memo <ref>`
2. User pays with the coinpay CLI.
3. `ssh pod@profullstack.com` → the server checks the subscription
(`pod_subscriptions.paid_until`); if unpaid it attempts one CoinPay
verification, then either admits or prints payment instructions and
disconnects.
Integration knobs (so the deployed CoinPay surface can evolve without a
rebuild):
- `AGENTBBS_COINPAY_PAY_TMPL` — pay-command template shown to users
(`%s` = payment reference).
- `AGENTBBS_COINPAY_VERIFY_CMD` — verifier command template; exit 0 = paid.
- `agentbbs grant-pod <user> <months>` — manual/ops grant path.
The payment reference is HMAC-derived from the user's key fingerprint, so
CoinPay memos reconcile to accounts without storing payment details.

50
go.mod Normal file
View file

@ -0,0 +1,50 @@
module github.com/profullstack/agentbbs
go 1.25.0
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v1.0.0
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
github.com/charmbracelet/wish v1.4.7
github.com/creack/pty v1.1.24
golang.org/x/crypto v0.37.0
modernc.org/sqlite v1.52.0
)
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/keygen v0.5.3 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/conpty v0.1.0 // indirect
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
github.com/charmbracelet/x/input v0.3.4 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/charmbracelet/x/termios v0.1.0 // indirect
github.com/charmbracelet/x/windows v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.24.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

127
go.sum Normal file
View file

@ -0,0 +1,127 @@
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k=
github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U=
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

63
internal/auth/auth.go Normal file
View file

@ -0,0 +1,63 @@
// Package auth resolves SSH connections into AgentBBS identities (PRD §4.4).
package auth
import (
"strings"
"github.com/charmbracelet/ssh"
gossh "golang.org/x/crypto/ssh"
)
// Kind classifies an identity.
type Kind string
const (
Guest Kind = "guest"
Member Kind = "member"
Agent Kind = "agent"
)
// User is the resolved identity for one session.
type User struct {
Name string
Kind Kind
PubKeyFP string // SHA256 fingerprint, empty for guests without a key
StoreID int64 // 0 for guests
}
// GuestNames are usernames that always map to an anonymous guest hub session.
var GuestNames = map[string]bool{"bbs": true, "play": true, "guest": true}
// PodNames are usernames that route to a personal pod instead of the hub.
// Pod access requires an active paid membership (PRD pods addendum).
var PodNames = map[string]bool{"pod": true}
// JoinNames are usernames that trigger the onboarding flow: register the
// visitor's public key, print instructions, and disconnect.
var JoinNames = map[string]bool{"join": true, "signup": true, "register": true}
// IsGuestName reports whether the SSH username requests anonymous hub access.
func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] }
// IsPodName reports whether the SSH username requests the pod route.
func IsPodName(u string) bool { return PodNames[strings.ToLower(u)] }
// IsJoinName reports whether the SSH username requests onboarding.
func IsJoinName(u string) bool { return JoinNames[strings.ToLower(u)] }
// KindFor infers the identity kind from a (non-guest) username.
// Usernames prefixed "agent-" are automated clients (PRD §3).
func KindFor(username string) Kind {
if strings.HasPrefix(strings.ToLower(username), "agent-") {
return Agent
}
return Member
}
// Fingerprint returns the SHA256 fingerprint for a session public key, or "".
func Fingerprint(key ssh.PublicKey) string {
if key == nil {
return ""
}
return gossh.FingerprintSHA256(key)
}

116
internal/hub/hub.go Normal file
View file

@ -0,0 +1,116 @@
// Package hub is the Bubble Tea model every BBS session lands in (PRD §4.1):
// it lists registered plugins and routes the session to the selection,
// reclaiming it when the plugin emits ExitMsg.
package hub
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
)
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
lockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
frameStyle = lipgloss.NewStyle().Padding(1, 2)
)
// Model is the hub menu.
type Model struct {
user auth.User
ctx plugin.Context
plugins []plugin.Plugin
cursor int
active tea.Model
width int
height int
note string
}
// New builds a hub for one session.
func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin) Model {
return Model{user: user, ctx: ctx, plugins: plugins}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Window size is shared with whichever model is active.
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = ws.Width, ws.Height
}
// A plugin owns the session until it emits ExitMsg (PRD §4.3).
if m.active != nil {
if _, ok := msg.(plugin.ExitMsg); ok {
m.active = nil
return m, nil
}
next, cmd := m.active.Update(msg)
m.active = next
return m, cmd
}
switch msg := msg.(type) {
case tea.KeyMsg:
m.note = ""
switch msg.String() {
case "q", "ctrl+c", "esc":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.plugins)-1 {
m.cursor++
}
case "enter":
p := m.plugins[m.cursor]
if p.RequiresAuth() && m.user.Kind == auth.Guest {
m.note = "members only — ssh join@ to register"
return m, nil
}
m.active = p.New(m.user, m.ctx)
cmds := []tea.Cmd{m.active.Init()}
if m.width > 0 {
next, cmd := m.active.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
m.active = next
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
}
return m, nil
}
func (m Model) View() string {
if m.active != nil {
return m.active.View()
}
who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind)
s := titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n\n"
for i, p := range m.plugins {
cur := " "
if i == m.cursor {
cur = cursorStyle.Render("> ")
}
label := p.Title()
if p.RequiresAuth() && m.user.Kind == auth.Guest {
label += lockStyle.Render(" [members]")
}
s += fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(p.Description()))
}
s += "\n" + dimStyle.Render("↑/↓ move · enter select · q quit")
if m.note != "" {
s += "\n" + lockStyle.Render(m.note)
}
return frameStyle.Render(s)
}

View file

@ -0,0 +1,79 @@
// Package payments gates paid features (the pod subscription, $1/mo) on
// CoinPay — the default LogicSRC payment/DID/wallet plugin.
//
// v1 integration is CLI-shaped: join@ hands the user a `coinpay` command
// carrying a unique payment reference, and verification shells out to the
// coinpay CLI. The exact command templates are env-configurable so the
// deployed CoinPay surface can evolve without a rebuild:
//
// AGENTBBS_COINPAY_PAY_TMPL e.g. "coinpay pay --to profullstack --amount 1 --currency USDC --memo %s"
// AGENTBBS_COINPAY_VERIFY_CMD e.g. "coinpay verify --memo %s" (exit 0 == paid)
//
// Operators can also grant manually: `agentbbs grant-pod <user> --months N`.
package payments
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// PodPriceLabel is the human-readable price for the pod membership.
const PodPriceLabel = "$1/mo"
// PodTerm is how much access one payment buys.
const PodTerm = 31 * 24 * time.Hour
// Reference derives a stable, short payment reference for a user+plan from
// the user's key fingerprint, so CoinPay memos can be reconciled to accounts.
func Reference(plan, pubkeyFP string) string {
mac := hmac.New(sha256.New, []byte("agentbbs."+plan))
mac.Write([]byte(pubkeyFP))
return "abbs-" + plan + "-" + hex.EncodeToString(mac.Sum(nil))[:12]
}
// PayCommand renders the coinpay command a user should run, with the payment
// reference substituted.
func PayCommand(ref string) string {
tmpl := os.Getenv("AGENTBBS_COINPAY_PAY_TMPL")
if tmpl == "" {
tmpl = "coinpay pay --to profullstack --amount 1 --currency USDC --memo %s"
}
if strings.Contains(tmpl, "%s") {
return fmt.Sprintf(tmpl, ref)
}
return tmpl + " " + ref
}
// Verify checks a payment reference against the coinpay CLI. It returns
// (paid, checked): checked is false when no verifier is configured or the
// coinpay binary is unavailable, so callers can fall back to store state.
func Verify(ref string) (paid bool, checked bool) {
tmpl := os.Getenv("AGENTBBS_COINPAY_VERIFY_CMD")
if tmpl == "" {
return false, false
}
var line string
if strings.Contains(tmpl, "%s") {
line = fmt.Sprintf(tmpl, ref)
} else {
line = tmpl + " " + ref
}
parts := strings.Fields(line)
if len(parts) == 0 {
return false, false
}
if _, err := exec.LookPath(parts[0]); err != nil {
return false, false
}
cmd := exec.Command(parts[0], parts[1:]...)
if err := cmd.Run(); err != nil {
return false, true
}
return true, true
}

45
internal/plugin/plugin.go Normal file
View file

@ -0,0 +1,45 @@
// Package plugin defines the AgentBBS plugin contract (PRD §4.3).
//
// A plugin is one interface implementation plus one registration in the hub.
// Plugins return control to the hub by emitting ExitMsg, never by quitting
// the session.
package plugin
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/sandbox"
"github.com/profullstack/agentbbs/internal/store"
)
// Context carries the shared services a plugin may use.
type Context struct {
Store store.Store
Sandbox *sandbox.Runner
// DataDir is the per-user persistent directory (members/agents only;
// empty for guests).
DataDir string
// AssetsDir is the read-only platform assets tree (wads, binaries).
AssetsDir string
}
// Plugin is the only integration point between a feature and the hub.
type Plugin interface {
// ID is a stable unique identifier, e.g. "arcade".
ID() string
// Title is the hub menu label.
Title() string
// Description is a one-line summary shown in the menu.
Description() string
// RequiresAuth reports whether guests are admitted.
RequiresAuth() bool
// New returns a fresh Bubble Tea model for one session.
New(user auth.User, ctx Context) tea.Model
}
// ExitMsg is emitted by a plugin model to hand the session back to the hub.
type ExitMsg struct{}
// Exit is a convenience command for plugins.
func Exit() tea.Msg { return ExitMsg{} }

170
internal/pods/pods.go Normal file
View file

@ -0,0 +1,170 @@
// Package pods gives paid members a personal Linux container over SSH
// (`ssh pod@host`) — "run shit in a docker-like" without root on the host.
//
// Engine preference: rootless Podman (daemonless; container root maps to an
// unprivileged host uid via user namespaces), falling back to Docker with a
// hardened profile (cap-drop ALL, no-new-privileges, non-root user, cpu/mem/
// pids caps). Either way the SSH user never touches the host OS.
package pods
import (
"fmt"
"io"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"github.com/charmbracelet/ssh"
"github.com/creack/pty"
)
// Manager provisions and attaches per-user pods.
type Manager struct {
engine string // "podman" or "docker"
image string
mu sync.Mutex
attached map[string]int // container name -> live session count
}
// Detect picks the best available engine. Returns an error if neither
// podman nor docker is present.
func Detect() (*Manager, error) {
image := os.Getenv("AGENTBBS_POD_IMAGE")
if image == "" {
image = "debian:stable-slim"
}
for _, eng := range []string{"podman", "docker"} {
if _, err := exec.LookPath(eng); err == nil {
return &Manager{engine: eng, image: image, attached: map[string]int{}}, nil
}
}
return nil, fmt.Errorf("pods: neither podman nor docker found")
}
// Engine reports the active container engine.
func (m *Manager) Engine() string { return m.engine }
var unsafeName = regexp.MustCompile(`[^a-zA-Z0-9_.-]`)
func (m *Manager) containerName(user string) string {
return "agentbbs-pod-" + unsafeName.ReplaceAllString(strings.ToLower(user), "-")
}
// ensure creates (or starts) the user's container and returns its name.
func (m *Manager) ensure(user string) (string, error) {
name := m.containerName(user)
if m.engine == "docker" {
// Under docker the pod runs as uid 1000 (never container root), so
// the named home volume must be owned by 1000. A trusted one-shot
// init container enforces that on every ensure — volumes can predate
// the container or survive recreation. Rootless podman doesn't need
// this: container root maps to the unprivileged host user.
init := exec.Command(m.engine, "run", "--rm",
"-v", name+"-home:/home/dev", m.image,
"sh", "-c", "chown 1000:1000 /home/dev")
if out, err := init.CombinedOutput(); err != nil {
return "", fmt.Errorf("pods: volume init failed: %v: %s", err, strings.TrimSpace(string(out)))
}
}
// Already exists?
if err := exec.Command(m.engine, "container", "inspect", name).Run(); err == nil {
_ = exec.Command(m.engine, "start", name).Run() // no-op if running
return name, nil
}
args := []string{
"run", "-d",
"--name", name,
"--hostname", "pod-" + unsafeName.ReplaceAllString(user, "-"),
"--memory", env("AGENTBBS_POD_MEM", "512m"),
"--cpus", env("AGENTBBS_POD_CPUS", "1"),
"--pids-limit", "256",
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--restart", "unless-stopped",
"-v", name + "-home:/home/dev",
"-w", "/home/dev",
"-e", "HOME=/home/dev",
}
if m.engine == "docker" {
// Rootless podman user-ns maps container root safely; under docker,
// refuse to hand out container root at all.
args = append(args, "--user", "1000:1000")
}
args = append(args, m.image, "sleep", "infinity")
out, err := exec.Command(m.engine, args...).CombinedOutput()
if err != nil {
return "", fmt.Errorf("pods: create failed: %v: %s", err, strings.TrimSpace(string(out)))
}
return name, nil
}
// Attach provisions the pod and wires the SSH session to a shell inside it.
// Blocks until the shell exits or the session closes.
func (m *Manager) Attach(s ssh.Session, user string) error {
ptyReq, winCh, hasPty := s.Pty()
if !hasPty {
return fmt.Errorf("pods: a PTY is required (ssh -t)")
}
name, err := m.ensure(user)
if err != nil {
return err
}
shell := env("AGENTBBS_POD_SHELL", "/bin/bash")
cmd := exec.Command(m.engine, "exec", "-it",
"-e", "TERM="+ptyReq.Term,
name, shell, "-l")
f, err := pty.Start(cmd)
if err != nil {
// busybox-ish images may lack bash
cmd = exec.Command(m.engine, "exec", "-it", "-e", "TERM="+ptyReq.Term, name, "/bin/sh", "-l")
f, err = pty.Start(cmd)
if err != nil {
return fmt.Errorf("pods: attach failed: %w", err)
}
}
defer f.Close()
m.ref(name, +1)
defer m.deref(name)
_ = pty.Setsize(f, &pty.Winsize{Rows: uint16(ptyReq.Window.Height), Cols: uint16(ptyReq.Window.Width)})
go func() {
for w := range winCh {
_ = pty.Setsize(f, &pty.Winsize{Rows: uint16(w.Height), Cols: uint16(w.Width)})
}
}()
go func() { _, _ = io.Copy(f, s) }() // ssh -> pod
_, _ = io.Copy(s, f) // pod -> ssh
_ = cmd.Wait()
return nil
}
func (m *Manager) ref(name string, d int) {
m.mu.Lock()
defer m.mu.Unlock()
m.attached[name] += d
}
// deref stops the container shortly after the last session detaches, unless
// AGENTBBS_POD_KEEP=1 keeps pods running between visits.
func (m *Manager) deref(name string) {
m.mu.Lock()
m.attached[name]--
last := m.attached[name] <= 0
m.mu.Unlock()
if last && os.Getenv("AGENTBBS_POD_KEEP") != "1" {
go func() { _ = exec.Command(m.engine, "stop", "-t", "2", name).Run() }()
}
}
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}

105
internal/sandbox/sandbox.go Normal file
View file

@ -0,0 +1,105 @@
// Package sandbox wraps game/agent subprocesses with per-session isolation
// and resource limits (PRD §7 S2). It prefers bubblewrap, falls back to
// prlimit, and degrades to a plain exec with a warning.
package sandbox
import (
"fmt"
"os/exec"
)
// Mode selects the isolation technology.
type Mode string
const (
ModeAuto Mode = "auto"
ModeBwrap Mode = "bwrap"
ModePrlimit Mode = "prlimit"
ModeNone Mode = "none"
)
// Limits are per-process resource caps.
type Limits struct {
CPUSeconds int // hard CPU-time cap (fork-bomb/runaway protection)
MemoryMB int
MaxProcs int
}
// DefaultLimits suit a single interactive game session.
var DefaultLimits = Limits{CPUSeconds: 3600, MemoryMB: 512, MaxProcs: 64}
// Runner builds sandboxed exec.Cmds.
type Runner struct {
mode Mode
}
// New picks the best available mode when ModeAuto is requested.
func New(mode Mode) *Runner {
if mode == "" || mode == ModeAuto {
switch {
case have("bwrap"):
mode = ModeBwrap
case have("prlimit"):
mode = ModePrlimit
default:
mode = ModeNone
}
}
return &Runner{mode: mode}
}
// Mode reports the active isolation mode.
func (r *Runner) Mode() Mode { return r.mode }
func have(bin string) bool { _, err := exec.LookPath(bin); return err == nil }
// Command wraps program+args in the runner's sandbox. workDir is the only
// writable path (savegames land there); everything else is read-only.
func (r *Runner) Command(workDir, program string, args ...string) *exec.Cmd {
lim := DefaultLimits
switch r.mode {
case ModeBwrap:
bw := []string{
"--ro-bind", "/", "/",
"--dev", "/dev",
"--proc", "/proc",
"--tmpfs", "/tmp",
"--bind", workDir, workDir,
"--unshare-net",
"--unshare-pid",
"--die-with-parent",
"--chdir", workDir,
}
// Resource caps still come from prlimit when available.
if have("prlimit") {
pl := prlimitArgs(lim)
full := append(pl, "bwrap")
full = append(full, bw...)
full = append(full, "--", program)
full = append(full, args...)
return exec.Command("prlimit", full...)
}
full := append(bw, "--", program)
full = append(full, args...)
return exec.Command("bwrap", full...)
case ModePrlimit:
full := append(prlimitArgs(lim), program)
full = append(full, args...)
cmd := exec.Command("prlimit", full...)
cmd.Dir = workDir
return cmd
default:
cmd := exec.Command(program, args...)
cmd.Dir = workDir
return cmd
}
}
func prlimitArgs(l Limits) []string {
return []string{
fmt.Sprintf("--cpu=%d", l.CPUSeconds),
fmt.Sprintf("--as=%d", l.MemoryMB*1024*1024),
fmt.Sprintf("--nproc=%d", l.MaxProcs),
"--",
}
}

214
internal/store/store.go Normal file
View file

@ -0,0 +1,214 @@
// Package store is the persistence layer (PRD §4.2): SQLite behind a Store
// interface so a move to Postgres is a driver swap, not a rewrite.
package store
import (
"database/sql"
"errors"
"time"
_ "modernc.org/sqlite"
)
// User is a persisted account (member or agent; guests are never stored).
type User struct {
ID int64
Name string
Kind string
PubKeyFP string
CreatedAt time.Time
}
// Score is one leaderboard entry.
type Score struct {
User string
Game string
Score int64
At time.Time
}
// Store is the persistence contract shared by all plugins.
type Store interface {
// EnsureUser returns the user with this name, creating it with the given
// kind and key fingerprint on first sight. If the name exists with a
// different fingerprint, ErrKeyMismatch is returned.
EnsureUser(name, kind, pubkeyFP string) (User, error)
// UserByFingerprint finds an account by SSH key fingerprint.
UserByFingerprint(fp string) (User, bool, error)
RecordSession(userID int64, username, remote, route string) (int64, error)
EndSession(sessionID int64) error
AddScore(userID int64, game string, score int64) error
TopScores(game string, n int) ([]Score, error)
// Pod subscription (paid membership, e.g. $1/mo via CoinPay).
PodPaidUntil(userID int64) (time.Time, bool, error)
GrantPod(userID int64, until time.Time, paymentRef string) error
Close() error
}
// ErrKeyMismatch means a username is already registered with another key.
var ErrKeyMismatch = errors.New("username registered with a different key")
type sqliteStore struct{ db *sql.DB }
// Open opens (and migrates) the SQLite store at path.
func Open(path string) (Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, err
}
return &sqliteStore{db: db}, nil
}
const schema = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
pubkey_fp TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY,
user_id INTEGER,
username TEXT NOT NULL,
remote_addr TEXT NOT NULL,
route TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
ended_at TEXT
);
CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
game TEXT NOT NULL,
score INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_scores_game ON scores(game, score DESC);
CREATE TABLE IF NOT EXISTS pod_subscriptions (
user_id INTEGER PRIMARY KEY REFERENCES users(id),
paid_until TEXT NOT NULL,
payment_ref TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
`
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
var u User
var created string
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE name = ?`, name).
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
switch {
case err == sql.ErrNoRows:
res, err := s.db.Exec(`INSERT INTO users (name, kind, pubkey_fp) VALUES (?,?,?)`, name, kind, fp)
if err != nil {
return User{}, err
}
id, _ := res.LastInsertId()
return User{ID: id, Name: name, Kind: kind, PubKeyFP: fp, CreatedAt: time.Now().UTC()}, nil
case err != nil:
return User{}, err
}
if u.PubKeyFP != "" && fp != "" && u.PubKeyFP != fp {
return User{}, ErrKeyMismatch
}
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
return u, nil
}
func (s *sqliteStore) UserByFingerprint(fp string) (User, bool, error) {
if fp == "" {
return User{}, false, nil
}
var u User
var created string
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE pubkey_fp = ?`, fp).
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
if err == sql.ErrNoRows {
return User{}, false, nil
}
if err != nil {
return User{}, false, err
}
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
return u, true, nil
}
func (s *sqliteStore) RecordSession(userID int64, username, remote, route string) (int64, error) {
var uid any
if userID > 0 {
uid = userID
}
res, err := s.db.Exec(`INSERT INTO sessions (user_id, username, remote_addr, route) VALUES (?,?,?,?)`,
uid, username, remote, route)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *sqliteStore) EndSession(id int64) error {
_, err := s.db.Exec(`UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, id)
return err
}
func (s *sqliteStore) AddScore(userID int64, game string, score int64) error {
_, err := s.db.Exec(`INSERT INTO scores (user_id, game, score) VALUES (?,?,?)`, userID, game, score)
return err
}
func (s *sqliteStore) TopScores(game string, n int) ([]Score, error) {
rows, err := s.db.Query(`
SELECT u.name, s.game, s.score, s.created_at
FROM scores s JOIN users u ON u.id = s.user_id
WHERE s.game = ? ORDER BY s.score DESC LIMIT ?`, game, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Score
for rows.Next() {
var sc Score
var at string
if err := rows.Scan(&sc.User, &sc.Game, &sc.Score, &at); err != nil {
return nil, err
}
sc.At, _ = time.Parse(time.RFC3339, at)
out = append(out, sc)
}
return out, rows.Err()
}
func (s *sqliteStore) PodPaidUntil(userID int64) (time.Time, bool, error) {
var until string
err := s.db.QueryRow(`SELECT paid_until FROM pod_subscriptions WHERE user_id = ?`, userID).Scan(&until)
if err == sql.ErrNoRows {
return time.Time{}, false, nil
}
if err != nil {
return time.Time{}, false, err
}
t, err := time.Parse(time.RFC3339, until)
return t, err == nil, err
}
func (s *sqliteStore) GrantPod(userID int64, until time.Time, ref string) error {
_, err := s.db.Exec(`
INSERT INTO pod_subscriptions (user_id, paid_until, payment_ref)
VALUES (?,?,?)
ON CONFLICT(user_id) DO UPDATE SET
paid_until = excluded.paid_until,
payment_ref = excluded.payment_ref,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
userID, until.UTC().Format(time.RFC3339), ref)
return err
}
func (s *sqliteStore) Close() error { return s.db.Close() }

47
plugins/about/about.go Normal file
View file

@ -0,0 +1,47 @@
// Package about is the smallest possible plugin: proof of the contract and
// the in-BBS pointer to the platform's entry points.
package about
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
)
type Plugin struct{}
func (Plugin) ID() string { return "about" }
func (Plugin) Title() string { return "About" }
func (Plugin) Description() string { return "What this place is and how to join" }
func (Plugin) RequiresAuth() bool { return false }
func (Plugin) New(user auth.User, _ plugin.Context) tea.Model {
return model{user: user}
}
type model struct{ user auth.User }
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(tea.KeyMsg); ok {
return m, plugin.Exit
}
return m, nil
}
var (
h = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
d = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
)
func (m model) View() string {
return lipgloss.NewStyle().Padding(1, 2).Render(
h.Render("AgentBBS") + " — a modern BBS over SSH for humans and AI agents.\n\n" +
" ssh bbs@profullstack.com this hub (guests welcome)\n" +
" ssh join@profullstack.com register your SSH key\n" +
" ssh pod@profullstack.com your own Linux pod (members, $1/mo via coinpay)\n\n" +
d.Render("Maintained by Profullstack, Inc. · AgentGames spec at logicsrc.com\n\npress any key to return"))
}

201
plugins/arcade/arcade.go Normal file
View file

@ -0,0 +1,201 @@
// Package arcade is the flagship plugin (PRD §5.1): classic terminal games.
// DOOM runs as a sandboxed external binary (doom-ascii + Freedoom); built-in
// TUI games (snake) feed the global leaderboards.
package arcade
import (
"fmt"
"os"
"path/filepath"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
)
type Plugin struct{}
func (Plugin) ID() string { return "arcade" }
func (Plugin) Title() string { return "Arcade" }
func (Plugin) Description() string { return "DOOM (ASCII), snake, leaderboards" }
func (Plugin) RequiresAuth() bool { return false }
func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model {
return newMenu(user, ctx)
}
// entry is one row in the arcade menu.
type entry struct {
label string
desc string
run func(m *menu) (tea.Model, tea.Cmd)
}
type menu struct {
user auth.User
ctx plugin.Context
entries []entry
cursor int
width int
height int
note string
child tea.Model // snake / leaderboard take over here
}
var (
tStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#fbbf24"))
dStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
cStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#fbbf24"))
eStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
)
func newMenu(user auth.User, ctx plugin.Context) *menu {
m := &menu{user: user, ctx: ctx}
for _, wad := range findWADs(ctx, user) {
wad := wad
m.entries = append(m.entries, entry{
label: "DOOM — " + filepath.Base(wad),
desc: "doom-ascii in a sandbox (24-bit color terminal recommended)",
run: func(m *menu) (tea.Model, tea.Cmd) { return m, m.launchDoom(wad) },
})
}
if len(m.entries) == 0 {
m.entries = append(m.entries, entry{
label: "DOOM — not installed",
desc: "run scripts/fetch-assets.sh on the host to build doom-ascii + Freedoom",
run: func(m *menu) (tea.Model, tea.Cmd) { m.note = "assets missing on host"; return m, nil },
})
}
m.entries = append(m.entries,
entry{
label: "Snake",
desc: "built-in; high scores hit the global leaderboard",
run: func(m *menu) (tea.Model, tea.Cmd) {
m.child = newSnake(m.user, m.ctx, m.width, m.height)
return m, m.child.Init()
},
},
entry{
label: "Leaderboard",
desc: "global top scores",
run: func(m *menu) (tea.Model, tea.Cmd) {
m.child = newBoard(m.ctx)
return m, m.child.Init()
},
},
)
return m
}
// findWADs lists platform WADs plus the member's own ~/wads (PRD §5.1, §9.1).
func findWADs(ctx plugin.Context, user auth.User) []string {
if doomBin(ctx) == "" {
return nil
}
var out []string
dirs := []string{filepath.Join(ctx.AssetsDir, "wads")}
if user.Kind != auth.Guest && ctx.DataDir != "" {
dirs = append(dirs, filepath.Join(ctx.DataDir, "wads"))
}
for _, dir := range dirs {
matches, _ := filepath.Glob(filepath.Join(dir, "*.wad"))
matchesUpper, _ := filepath.Glob(filepath.Join(dir, "*.WAD"))
out = append(out, append(matches, matchesUpper...)...)
}
return out
}
func doomBin(ctx plugin.Context) string {
p := filepath.Join(ctx.AssetsDir, "bin", "doom_ascii")
if _, err := os.Stat(p); err == nil {
return p
}
return ""
}
// launchDoom suspends the TUI and bridges the session to a sandboxed
// doom-ascii on a real PTY. Savegames land in the per-user work dir.
func (m *menu) launchDoom(wad string) tea.Cmd {
bin := doomBin(m.ctx)
work := m.ctx.DataDir
if work == "" { // guests: throwaway saves
work, _ = os.MkdirTemp("", "agentbbs-guest-doom-")
} else {
work = filepath.Join(work, "doom", strings.TrimSuffix(filepath.Base(wad), filepath.Ext(wad)))
_ = os.MkdirAll(work, 0o755)
}
cmd := m.ctx.Sandbox.Command(work, bin, "-iwad", wad)
return tea.Exec(newPtyExec(cmd, m.width, m.height), func(err error) tea.Msg {
return doomDoneMsg{err: err}
})
}
type doomDoneMsg struct{ err error }
func (m *menu) Init() tea.Cmd { return nil }
func (m *menu) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = ws.Width, ws.Height
}
if m.child != nil {
if _, ok := msg.(backMsg); ok {
m.child = nil
return m, nil
}
next, cmd := m.child.Update(msg)
m.child = next
return m, cmd
}
switch msg := msg.(type) {
case doomDoneMsg:
if msg.err != nil {
m.note = "doom exited: " + msg.err.Error()
}
return m, nil
case tea.KeyMsg:
m.note = ""
switch msg.String() {
case "q", "esc":
return m, plugin.Exit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.entries)-1 {
m.cursor++
}
case "enter":
return m.entries[m.cursor].run(m)
}
}
return m, nil
}
func (m *menu) View() string {
if m.child != nil {
return m.child.View()
}
s := tStyle.Render("Arcade") + "\n\n"
for i, e := range m.entries {
cur := " "
if i == m.cursor {
cur = cStyle.Render("> ")
}
s += fmt.Sprintf("%s%s\n %s\n", cur, e.label, dStyle.Render(e.desc))
}
s += "\n" + dStyle.Render("↑/↓ move · enter play · q back to hub")
if m.note != "" {
s += "\n" + eStyle.Render(m.note)
}
return lipgloss.NewStyle().Padding(1, 2).Render(s)
}
// backMsg returns from a child (snake/leaderboard) to the arcade menu.
type backMsg struct{}
func back() tea.Msg { return backMsg{} }

58
plugins/arcade/board.go Normal file
View file

@ -0,0 +1,58 @@
package arcade
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/store"
)
// board renders the global top scores (PRD §5.1 leaderboards).
type board struct {
ctx plugin.Context
scores []store.Score
err error
}
func newBoard(ctx plugin.Context) *board { return &board{ctx: ctx} }
func (b *board) Init() tea.Cmd {
return func() tea.Msg {
scores, err := b.ctx.Store.TopScores("snake", 10)
return boardMsg{scores: scores, err: err}
}
}
type boardMsg struct {
scores []store.Score
err error
}
func (b *board) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case boardMsg:
b.scores, b.err = msg.scores, msg.err
case tea.KeyMsg:
return b, back
}
return b, nil
}
func (b *board) View() string {
s := tStyle.Render("Leaderboard — snake") + "\n\n"
switch {
case b.err != nil:
s += eStyle.Render("error: " + b.err.Error())
case len(b.scores) == 0:
s += dStyle.Render("no scores yet — be the first")
default:
for i, sc := range b.scores {
s += fmt.Sprintf("%2d. %-20s %6d\n", i+1, sc.User, sc.Score)
}
}
s += "\n" + dStyle.Render("any key to return")
return lipgloss.NewStyle().Padding(1, 2).Render(s)
}

63
plugins/arcade/ptyexec.go Normal file
View file

@ -0,0 +1,63 @@
package arcade
import (
"io"
"os/exec"
tea "github.com/charmbracelet/bubbletea"
"github.com/creack/pty"
)
// ptyExec is a tea.ExecCommand that runs the child on a real host PTY and
// bridges it to the session streams. Needed because doom-ascii (like most
// raw-mode terminal programs) demands an actual TTY, and over SSH the
// bubbletea program's stdin/stdout are session streams, not a host TTY.
type ptyExec struct {
cmd *exec.Cmd
stdin io.Reader
stdout io.Writer
width, height int
}
func newPtyExec(cmd *exec.Cmd, width, height int) *ptyExec {
return &ptyExec{cmd: cmd, width: width, height: height}
}
func (p *ptyExec) SetStdin(r io.Reader) { p.stdin = r }
func (p *ptyExec) SetStdout(w io.Writer) { p.stdout = w }
func (p *ptyExec) SetStderr(io.Writer) {}
var _ tea.ExecCommand = (*ptyExec)(nil)
func (p *ptyExec) Run() error {
f, err := pty.StartWithSize(p.cmd, &pty.Winsize{
Rows: uint16(max(p.height, 24)),
Cols: uint16(max(p.width, 80)),
})
if err != nil {
return err
}
defer f.Close()
done := make(chan struct{})
go func() { _, _ = io.Copy(p.stdout, f); close(done) }()
go func() {
_, _ = io.Copy(f, p.stdin)
// Session input is gone (disconnect): don't leave the game orphaned
// on the host (PRD §7 S3 — abandoned sessions are reaped).
if p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
}
}()
err = p.cmd.Wait()
<-done
return err
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

169
plugins/arcade/snake.go Normal file
View file

@ -0,0 +1,169 @@
package arcade
import (
"fmt"
"math/rand"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
)
// snake is the built-in leaderboard game: simple, fair, trivially judged.
type snake struct {
user auth.User
ctx plugin.Context
w, h int // board size in cells
body []pos
dir pos
food pos
score int64
dead bool
saved bool
}
type pos struct{ x, y int }
type tickMsg time.Time
func tick() tea.Cmd {
return tea.Tick(120*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func newSnake(user auth.User, ctx plugin.Context, termW, termH int) *snake {
w, h := 32, 16
if termW > 0 && termW/2-4 < w {
w = termW/2 - 4
}
if termH > 0 && termH-8 < h {
h = termH - 8
}
if w < 10 {
w = 10
}
if h < 8 {
h = 8
}
s := &snake{user: user, ctx: ctx, w: w, h: h,
body: []pos{{w / 2, h / 2}}, dir: pos{1, 0}}
s.placeFood()
return s
}
func (s *snake) placeFood() {
for {
p := pos{rand.Intn(s.w), rand.Intn(s.h)}
if !s.hits(p) {
s.food = p
return
}
}
}
func (s *snake) hits(p pos) bool {
for _, b := range s.body {
if b == p {
return true
}
}
return false
}
func (s *snake) Init() tea.Cmd { return tick() }
func (s *snake) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "esc":
return s, back
case "up", "w":
if s.dir.y == 0 {
s.dir = pos{0, -1}
}
case "down", "s":
if s.dir.y == 0 {
s.dir = pos{0, 1}
}
case "left", "a":
if s.dir.x == 0 {
s.dir = pos{-1, 0}
}
case "right", "d":
if s.dir.x == 0 {
s.dir = pos{1, 0}
}
case "r":
if s.dead {
ns := newSnake(s.user, s.ctx, 0, 0)
ns.w, ns.h = s.w, s.h
return ns, ns.Init()
}
}
case tickMsg:
if s.dead {
return s, nil
}
head := pos{s.body[0].x + s.dir.x, s.body[0].y + s.dir.y}
if head.x < 0 || head.y < 0 || head.x >= s.w || head.y >= s.h || s.hits(head) {
s.dead = true
// Guests play, members persist (PRD §5.1).
if !s.saved && s.user.Kind != auth.Guest && s.user.StoreID > 0 && s.score > 0 {
_ = s.ctx.Store.AddScore(s.user.StoreID, "snake", s.score)
s.saved = true
}
return s, nil
}
s.body = append([]pos{head}, s.body...)
if head == s.food {
s.score += 10
s.placeFood()
} else {
s.body = s.body[:len(s.body)-1]
}
return s, tick()
}
return s, nil
}
var (
snakeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
foodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
wallStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
)
func (s *snake) View() string {
out := fmt.Sprintf("Snake — score %d", s.score)
if s.dead {
out += " ☠ dead (r restart · q back)"
}
out += "\n" + wallStyle.Render("┌"+repeat("──", s.w)+"┐") + "\n"
for y := 0; y < s.h; y++ {
row := wallStyle.Render("│")
for x := 0; x < s.w; x++ {
switch {
case s.hits(pos{x, y}):
row += snakeStyle.Render("██")
case s.food == pos{x, y}:
row += foodStyle.Render("◆ ")
default:
row += " "
}
}
out += row + wallStyle.Render("│") + "\n"
}
out += wallStyle.Render("└" + repeat("──", s.w) + "┘")
return lipgloss.NewStyle().Padding(1, 2).Render(out)
}
func repeat(s string, n int) string {
out := ""
for i := 0; i < n; i++ {
out += s
}
return out
}

58
scripts/fetch-assets.sh Executable file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Build doom-ascii and fetch the default (legally clean) game content into
# ./assets — Freedoom by default (PRD §9.1). Run on the host before enabling
# the arcade's DOOM entries.
#
# scripts/fetch-assets.sh [--shareware]
#
# --shareware additionally fetches the freely redistributable doom1.wad
# shareware episode.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ASSETS="$ROOT/assets"
BUILD="$ROOT/.build"
FREEDOOM_VERSION="${FREEDOOM_VERSION:-0.13.0}"
mkdir -p "$ASSETS/bin" "$ASSETS/wads" "$BUILD"
# --- doom-ascii -------------------------------------------------------------
if [ ! -x "$ASSETS/bin/doom_ascii" ]; then
echo ">> building doom-ascii"
if [ ! -d "$BUILD/doom-ascii" ]; then
git clone --depth 1 https://github.com/wojciech-graj/doom-ascii "$BUILD/doom-ascii"
fi
make -C "$BUILD/doom-ascii" -j"$(nproc)"
BIN="$(find "$BUILD/doom-ascii" -maxdepth 3 -type f \( -name 'doom-ascii' -o -name 'doom_ascii' \) -executable | head -1)"
if [ -z "$BIN" ]; then
echo "!! doom-ascii build produced no binary" >&2
exit 1
fi
cp "$BIN" "$ASSETS/bin/doom_ascii"
echo ">> installed $ASSETS/bin/doom_ascii"
else
echo ">> doom-ascii already built"
fi
# --- Freedoom ---------------------------------------------------------------
if [ ! -f "$ASSETS/wads/freedoom1.wad" ]; then
echo ">> fetching Freedoom $FREEDOOM_VERSION"
ZIP="$BUILD/freedoom.zip"
curl -fsSL -o "$ZIP" \
"https://github.com/freedoom/freedoom/releases/download/v$FREEDOOM_VERSION/freedoom-$FREEDOOM_VERSION.zip"
unzip -o -j "$ZIP" '*/freedoom1.wad' '*/freedoom2.wad' -d "$ASSETS/wads"
echo ">> installed freedoom1.wad freedoom2.wad"
else
echo ">> Freedoom already present"
fi
# --- Doom shareware (optional) ----------------------------------------------
if [ "${1:-}" = "--shareware" ] && [ ! -f "$ASSETS/wads/doom1.wad" ]; then
echo ">> fetching Doom shareware episode"
curl -fsSL -o "$ASSETS/wads/doom1.wad" \
"https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad"
echo ">> installed doom1.wad (shareware)"
fi
echo ">> done. WADs:"
ls -l "$ASSETS/wads"