mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-14 14:57:27 +00:00
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:
commit
f3b085a08f
21 changed files with 2405 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/agentbbs
|
||||
/data/
|
||||
/assets/
|
||||
/.build/
|
||||
*.db
|
||||
*.log
|
||||
.env
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
77
README.md
Normal 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
311
cmd/agentbbs/main.go
Normal 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
367
docs/PRD.md
Normal 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
57
docs/pods.md
Normal 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
50
go.mod
Normal 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
127
go.sum
Normal 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
63
internal/auth/auth.go
Normal 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
116
internal/hub/hub.go
Normal 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)
|
||||
}
|
||||
79
internal/payments/payments.go
Normal file
79
internal/payments/payments.go
Normal 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
45
internal/plugin/plugin.go
Normal 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
170
internal/pods/pods.go
Normal 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
105
internal/sandbox/sandbox.go
Normal 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
214
internal/store/store.go
Normal 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
47
plugins/about/about.go
Normal 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
201
plugins/arcade/arcade.go
Normal 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
58
plugins/arcade/board.go
Normal 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
63
plugins/arcade/ptyexec.go
Normal 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
169
plugins/arcade/snake.go
Normal 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
58
scripts/fetch-assets.sh
Executable 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue