commit f3b085a08f60222d2436f62f84add577ba24a830 Author: Anthony Ettinger Date: Thu Jun 11 11:08:17 2026 +0000 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 - @ 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11893b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/agentbbs +/data/ +/assets/ +/.build/ +*.db +*.log +.env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4c13c82 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1656d83 --- /dev/null +++ b/README.md @@ -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 @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. diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go new file mode 100644 index 0000000..d36cc5c --- /dev/null +++ b/cmd/agentbbs/main.go @@ -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 @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 ") + 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() +} diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..cb3dab2 --- /dev/null +++ b/docs/PRD.md @@ -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. diff --git a/docs/pods.md b/docs/pods.md new file mode 100644 index 0000000..b7aae4d --- /dev/null +++ b/docs/pods.md @@ -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 @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 ` +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 ` — 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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bbf2757 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5797d2a --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..aa737ea --- /dev/null +++ b/internal/auth/auth.go @@ -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) +} diff --git a/internal/hub/hub.go b/internal/hub/hub.go new file mode 100644 index 0000000..b23bb20 --- /dev/null +++ b/internal/hub/hub.go @@ -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) +} diff --git a/internal/payments/payments.go b/internal/payments/payments.go new file mode 100644 index 0000000..0a4181a --- /dev/null +++ b/internal/payments/payments.go @@ -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 --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 +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go new file mode 100644 index 0000000..4f32b24 --- /dev/null +++ b/internal/plugin/plugin.go @@ -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{} } diff --git a/internal/pods/pods.go b/internal/pods/pods.go new file mode 100644 index 0000000..3a5746b --- /dev/null +++ b/internal/pods/pods.go @@ -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 +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go new file mode 100644 index 0000000..bde9561 --- /dev/null +++ b/internal/sandbox/sandbox.go @@ -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), + "--", + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..5216c97 --- /dev/null +++ b/internal/store/store.go @@ -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() } diff --git a/plugins/about/about.go b/plugins/about/about.go new file mode 100644 index 0000000..8461d27 --- /dev/null +++ b/plugins/about/about.go @@ -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")) +} diff --git a/plugins/arcade/arcade.go b/plugins/arcade/arcade.go new file mode 100644 index 0000000..7fb120c --- /dev/null +++ b/plugins/arcade/arcade.go @@ -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{} } diff --git a/plugins/arcade/board.go b/plugins/arcade/board.go new file mode 100644 index 0000000..3b1d56c --- /dev/null +++ b/plugins/arcade/board.go @@ -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) +} diff --git a/plugins/arcade/ptyexec.go b/plugins/arcade/ptyexec.go new file mode 100644 index 0000000..a2cdda3 --- /dev/null +++ b/plugins/arcade/ptyexec.go @@ -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 +} diff --git a/plugins/arcade/snake.go b/plugins/arcade/snake.go new file mode 100644 index 0000000..1cbf529 --- /dev/null +++ b/plugins/arcade/snake.go @@ -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 +} diff --git a/scripts/fetch-assets.sh b/scripts/fetch-assets.sh new file mode 100755 index 0000000..8db0192 --- /dev/null +++ b/scripts/fetch-assets.sh @@ -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"