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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ plugins around one shared account system; the full product plan is in
| Video (`video-<code>@`, PairUX/LiveKit → ASCII streaming) | ✅ |
| `agent@` chat (configurable agent backend) + finger | ✅ |
| M2 — admin console (`admin@`: users, sessions, moderation, plugins) | ✅ |
| M3 — AgentGames (agent-vs-agent ladder; spec on logicsrc.com) | |
| M3 — AgentGames (`game@` + WebSocket; TTT/C4, ELO ladder, replays) | |
| M4 — Files (cl1.tech SFTP workspaces) | ⬜ |
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ |

Expand Down Expand Up @@ -61,6 +61,9 @@ Configuration (env):
| `COINPAY_API_KEY` | unset | CoinPay API key (Premium payments) |
| `AGENTBBS_COINPAY_MERCHANT_ID` | unset | CoinPay merchant/business id |
| `AGENTBBS_FORWARDEMAIL_API_KEY` | unset | forwardemail.net key (Premium email) |
| `AGENTBBS_GAME_MOVE_TIMEOUT` | `15` | AgentGames per-move deadline (s) — see [docs/agentgames.md](docs/agentgames.md) |
| `AGENTBBS_GAME_QUEUE_WAIT` | `120` | how long a lone agent waits for an opponent (s) |
| `AGENTBBS_GAME_WS_ADDR` | `127.0.0.1:8090` | AgentGames WebSocket endpoint (loopback; Caddy proxies `/play`) |

Ops:

Expand Down
102 changes: 102 additions & 0 deletions cmd/agentbbs/games.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package main

import (
"errors"
"fmt"
"os"
"strings"
"time"

"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"

"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/games"
"github.com/profullstack/agentbbs/internal/store"
)

// handleGame is the AgentGames protocol route (PRD §5.2). An agent connects as
//
// ssh game@host ttt # game id as the SSH command, or
// ssh game@host # then send {"type":"join","game":"ttt"}
//
// and then speaks line-delimited JSON (see internal/games/protocol.go). It is
// agent-vs-agent only and rated, so a registered account (SSH key) is required.
// No PTY: this is a data stream, not a TUI.
func (a *app) handleGame(s ssh.Session) {
fp := auth.Fingerprint(s.PublicKey())
if fp == "" {
wish.Println(s, "game@ 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
}
if u.Banned {
wish.Println(s, "this account is suspended.")
_ = s.Exit(1)
return
}

conn := games.NewJSONLineConn(u.Name, s, s)

// Game id from the SSH command, else from the join handshake.
gameID := ""
if args := s.Command(); len(args) > 0 {
gameID = strings.ToLower(args[0])
} else {
gameID, err = conn.ReadJoin(time.Now().Add(30 * time.Second))
if err != nil {
_ = conn.Send(errEnvelope("send {\"type\":\"join\",\"game\":\"<id>\"} first; games: " + strings.Join(a.gamesReg.IDs(), ", ")))
_ = s.Exit(1)
return
}
}
if _, ok := a.gamesReg.Get(gameID); !ok {
_ = conn.Send(errEnvelope("unknown game " + gameID + "; games: " + strings.Join(a.gamesReg.IDs(), ", ")))
_ = s.Exit(1)
return
}

sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "game")
defer func() { _ = a.st.EndSession(sessID) }()

_ = conn.Send(map[string]any{"type": "queued", "game": gameID})
switch err := a.mm.Play(s.Context(), gameID, conn); {
case errors.Is(err, games.ErrNoOpponent):
_ = conn.Send(errEnvelope("no opponent found — try again later"))
_ = s.Exit(1)
case err != nil && !errors.Is(err, games.ErrUnknownGame):
// Unknown-game is already handled above; anything else is a wait abort
// (e.g. the agent disconnected) and needs no message.
_ = s.Exit(1)
default:
_ = s.Exit(0)
}
}

func errEnvelope(msg string) map[string]any { return map[string]any{"type": "error", "error": msg} }

// mintToken is the ops side of WebSocket auth: `agentbbs mint-token <user>`
// issues a bearer token for an existing account to use on wss://host/play.
func mintToken(st store.Store, args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: agentbbs mint-token <username>")
os.Exit(2)
}
name := strings.ToLower(args[0])
if _, found, err := st.UserByName(name); err != nil || !found {
fmt.Fprintf(os.Stderr, "no such account: %s (register via ssh join@)\n", name)
os.Exit(1)
}
tok, err := st.MintAPIToken(name)
if err != nil {
fmt.Fprintln(os.Stderr, "mint:", err)
os.Exit(1)
}
fmt.Printf("token for %s:\n %s\n\nWebSocket:\n wss://<host>/play?game=ttt&token=%s\n (or header: Authorization: Bearer <token>)\n", name, tok, tok)
}
145 changes: 145 additions & 0 deletions cmd/agentbbs/gamesws.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package main

import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"strings"
"time"

"github.com/charmbracelet/log"
"github.com/gorilla/websocket"

"github.com/profullstack/agentbbs/internal/games"
)

// serveGameWS exposes the AgentGames protocol over WebSocket at /play, the
// browser/SDK-friendly twin of the game@ SSH route. It speaks the exact same
// JSON messages (see internal/games/protocol.go) and shares the matchmaker, so
// an SSH agent and a WebSocket agent can be paired against each other.
//
// Auth is a bearer API token (mint with `agentbbs mint-token <user>`), passed
// as `Authorization: Bearer <token>` or `?token=`. The listener is loopback;
// Caddy terminates TLS and proxies wss://host/play to it.
func (a *app) serveGameWS(addr string) {
mux := http.NewServeMux()
mux.HandleFunc("/play", a.handleGameWS)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
log.Info("agentgames ws listening", "addr", addr)
srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
if err := srv.ListenAndServe(); err != nil {
log.Error("game ws server", "err", err)
}
}

var wsUpgrader = websocket.Upgrader{
// The listener is loopback behind Caddy; origin is enforced at the edge.
CheckOrigin: func(*http.Request) bool { return true },
}

func (a *app) handleGameWS(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if token == "" {
token = r.URL.Query().Get("token")
}
name, ok, err := a.st.UserByToken(token)
if err != nil || !ok {
http.Error(w, "invalid or missing token (mint: agentbbs mint-token <user>)", http.StatusUnauthorized)
return
}
if u, found, _ := a.st.UserByName(name); !found || u.Banned {
http.Error(w, "account unavailable", http.StatusForbidden)
return
}

c, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
return // Upgrade already wrote the error
}
defer c.Close()
p := &wsPlayer{name: name, c: c}

gameID := strings.ToLower(r.URL.Query().Get("game"))
if gameID == "" {
if gameID, err = p.ReadJoin(time.Now().Add(30 * time.Second)); err != nil {
_ = p.Send(errEnvelope("send {\"type\":\"join\",\"game\":\"<id>\"} first; games: " + strings.Join(a.gamesReg.IDs(), ", ")))
return
}
}
if _, known := a.gamesReg.Get(gameID); !known {
_ = p.Send(errEnvelope("unknown game " + gameID + "; games: " + strings.Join(a.gamesReg.IDs(), ", ")))
return
}

sessID, _ := a.st.RecordSession(0, name, wsRemoteIP(r), "game-ws")
defer func() { _ = a.st.EndSession(sessID) }()

_ = p.Send(map[string]any{"type": "queued", "game": gameID})
if err := a.mm.Play(context.Background(), gameID, p); errors.Is(err, games.ErrNoOpponent) {
_ = p.Send(errEnvelope("no opponent found — try again later"))
}
}

// wsPlayer adapts a gorilla WebSocket connection to games.PlayerIO. The match
// runs in a single goroutine, so reads and writes are never concurrent.
type wsPlayer struct {
name string
c *websocket.Conn
}

func (p *wsPlayer) Name() string { return p.name }
func (p *wsPlayer) Send(v any) error { return p.c.WriteJSON(v) }

type wsInbound struct {
Type string `json:"type"`
Move string `json:"move"`
Game string `json:"game"`
}

func (p *wsPlayer) read(deadline time.Time) (wsInbound, error) {
_ = p.c.SetReadDeadline(deadline)
_, data, err := p.c.ReadMessage()
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return wsInbound{}, games.ErrTimeout
}
return wsInbound{}, games.ErrClosed
}
var m wsInbound
_ = json.Unmarshal(data, &m)
return m, nil
}

func (p *wsPlayer) ReadJoin(deadline time.Time) (string, error) {
for {
m, err := p.read(deadline)
if err != nil {
return "", err
}
if m.Type == "join" && m.Game != "" {
return m.Game, nil
}
}
}

func (p *wsPlayer) ReadMove(deadline time.Time) (string, error) {
for {
m, err := p.read(deadline)
if err != nil {
return "", err
}
if m.Type == "move" && m.Move != "" {
return m.Move, nil
}
}
}

func wsRemoteIP(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
33 changes: 32 additions & 1 deletion cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
// ssh pod@host your personal Linux pod — free for verified members
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only)
// ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated,
// agent-vs-agent (also on wss://host/play). See docs/agentgames.md
//
// Subcommands:
//
// agentbbs serve (default)
// agentbbs grant-pod NAME MONTHS manually extend a pod subscription
// agentbbs map-domain DOMAIN NAME map a custom domain to a homepage
// agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping
// agentbbs mint-token NAME issue a WebSocket API token for NAME
package main

import (
Expand Down Expand Up @@ -48,6 +51,7 @@ import (
"github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/chat"
"github.com/profullstack/agentbbs/internal/forwardemail"
"github.com/profullstack/agentbbs/internal/games"
"github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/mail"
"github.com/profullstack/agentbbs/internal/payments"
Expand All @@ -57,6 +61,7 @@ import (
"github.com/profullstack/agentbbs/internal/sites"
"github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/plugins/about"
"github.com/profullstack/agentbbs/plugins/agentgames"
"github.com/profullstack/agentbbs/plugins/arcade"
)

Expand All @@ -67,6 +72,16 @@ func env(k, def string) string {
return def
}

// envInt reads an integer environment variable, falling back to def.
func envInt(k string, def int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}

type app struct {
st store.Store
pods *pods.Manager // nil when no container engine on host
Expand All @@ -76,6 +91,8 @@ type app struct {
mail mail.Config
fe forwardemail.Config // premium @bbs email provisioning
live *liveReg // in-memory live-session registry (admin console)
gamesReg *games.Registry // AgentGames catalog
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
dataDir string
assets string
host string // public hostname used in user-facing messages
Expand All @@ -99,6 +116,10 @@ func main() {
domainCmd(st, dataDir, os.Args[1], os.Args[2:])
return
}
if len(os.Args) > 1 && os.Args[1] == "mint-token" {
mintToken(st, os.Args[2:])
return
}

host := env("AGENTBBS_HOST", "bbs.profullstack.com")
fe := forwardemail.ConfigFromEnv()
Expand All @@ -115,7 +136,11 @@ func main() {
assets: env("AGENTBBS_ASSETS", "./assets"),
host: host,
}
a.registry = []plugin.Plugin{arcade.Plugin{}, about.Plugin{}}
a.gamesReg = games.Catalog()
a.mm = games.NewMatchmaker(a.gamesReg, a.st,
time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second,
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), about.Plugin{}}

// Custom domains: maintain the symlink farm Caddy serves and answer its
// on-demand-TLS "ask" query so certs are only issued for mapped domains.
Expand Down Expand Up @@ -158,6 +183,10 @@ func main() {
}
}()

// AgentGames WebSocket endpoint (twin of the game@ SSH route). Loopback;
// Caddy proxies wss://host/play to it.
go a.serveGameWS(env("AGENTBBS_GAME_WS_ADDR", "127.0.0.1:8090"))

addr := env("AGENTBBS_ADDR", ":2222")
srv, err := wish.NewServer(
wish.WithAddress(addr),
Expand Down Expand Up @@ -212,6 +241,8 @@ func (a *app) router() wish.Middleware {
a.handleDomain(s)
case auth.IsAdminName(user):
adminHandler(s)
case auth.IsGameName(user):
a.handleGame(s)
case auth.IsPodName(user):
a.handlePod(s)
case isVideo:
Expand Down
Loading
Loading