Fix/pod public html bind mount (#30)

* feat(pods/admin): root admin alias, default-caps cleanup, pod rebuild script

- auth: add `root` as an admin-console route alias (alongside admin/sysop);
  still gated by $AGENTBBS_ADMINS — the name confers nothing on its own.
- pods: drop the now-redundant tuneApt apt-sandbox hack. Rootless podman keeps
  its default capability set, so apt/chown/su work without disabling the
  download sandbox.
- scripts/rebuild-pods.sh: recreate all member pods (keeps home volumes) so
  they pick up the current container profile on next `ssh pod@`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(arcade,ui): 80s arcade classics + shared menu theme

Arcade games (PRD §5.1), generalizing the sandboxed-PTY DOOM path into an
external-game registry: Space Invaders (nInvaders), Pac-Man (pacman4console),
Tetris (tint/vitetris), Moon Patrol (moon-buggy). Binaries resolve from
assets/bin, PATH, then /usr/games, so a distro install or a hand-built binary
lights each game up; missing games are skipped with a discovery hint. Installed
on the host via `scripts/fetch-assets.sh --arcade` (apt), wired into setup.sh
behind FETCH_ARCADE (default on).

UI: new shared ui.Theme.MenuItem widget (accent cursor + badge, description
shown only for the focused row) adopted by the hub and arcade menus; the hub
groups rows under Features/Sessions headers and the arcade under
DOOM/ARCADE/BUILT-IN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-15 08:06:32 -07:00 committed by GitHub
parent 362b47fdde
commit e0a267e343
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 539 additions and 138 deletions

View file

@ -3,11 +3,14 @@
package about
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/ui"
)
type Plugin struct{}
@ -26,22 +29,57 @@ 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
if k, ok := msg.(tea.KeyMsg); ok {
switch k.String() {
case "esc", "q", "enter", "ctrl+c", " ":
return m, plugin.Exit
}
}
return m, nil
}
var (
h = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
d = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
theme = ui.New(ui.Green)
taglineStyle = lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("245"))
cmdStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Cyan)
)
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"))
// route is one connection entry point shown in the CONNECT card.
type route struct {
cmd, desc string
badgeVar, tag string
}
func (m model) View() string {
const cmdW, descW = 28, 22
routes := []route{
{"ssh bbs@profullstack.com", "the public hub", ui.BadgeOK, "guests welcome"},
{"ssh join@profullstack.com", "register your SSH key", ui.BadgeInfo, "free"},
{"ssh pod@profullstack.com", "your own Linux pod", ui.BadgeGold, "$1/mo · members"},
}
rows := make([]string, 0, len(routes))
for _, r := range routes {
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Left,
cmdStyle.Width(cmdW).Render(r.cmd),
ui.Body.Width(descW).Render(r.desc),
ui.Badge(r.badgeVar, r.tag),
))
}
footer := ui.Dim.Render("Maintained by Profullstack, Inc.") + "\n" +
ui.Dim.Render("AgentGames spec → logicsrc.com")
body := lipgloss.JoinVertical(lipgloss.Left,
theme.Title("AgentBBS"),
taglineStyle.Render("a modern BBS over SSH — for humans and AI agents"),
"",
theme.Card("Connect", strings.Join(rows, "\n")),
"",
footer,
"",
ui.KeyBar("esc/q return to menu"),
)
return ui.Frame.Render(body)
}

View file

@ -18,6 +18,7 @@ import (
"github.com/profullstack/agentbbs/internal/games"
"github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/internal/ui"
)
// Plugin is the AgentGames hub entry.
@ -52,12 +53,12 @@ const (
)
var (
title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
dim = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
head = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa"))
warn = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
frame = lipgloss.NewStyle().Padding(1, 2)
title = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
dim = ui.Dim
cursor = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
head = lipgloss.NewStyle().Bold(true).Foreground(ui.Blue)
warn = ui.Danger
frame = ui.Frame
)
var actions = []string{"Ladder", "Replays", "Play vs bot"}
@ -302,7 +303,7 @@ func (m *model) View() string {
case scPlay:
body, help = m.viewPlay()
}
out := title.Render("AgentGames") + "\n\n" + body + "\n" + dim.Render(help)
out := title.Render("AgentGames") + "\n\n" + body + "\n" + ui.KeyBar(help)
if m.note != "" {
out += "\n" + m.note
}
@ -393,7 +394,7 @@ func (m *model) viewPlay() (string, string) {
func (m *model) row(i int) string {
if i == m.cursor {
return cursor.Render("> ")
return cursor.Render(" ")
}
return " "
}

View file

@ -1,37 +1,65 @@
// 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.
// DOOM and the 80s arcade classics (Space Invaders, Pac-Man, Tetris, Moon
// Patrol) run as sandboxed external binaries on a real PTY; built-in TUI games
// (snake) feed the global leaderboards.
package arcade
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/ui"
)
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) Description() string { return "DOOM, Space Invaders, Pac-Man, Tetris, snake & leaderboards" }
func (Plugin) RequiresAuth() bool { return false }
func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model {
return newMenu(user, ctx)
}
// extGame is an 80s arcade classic launched as a sandboxed subprocess on a real
// PTY — the doom-ascii pattern, generalized. The binary is resolved from the
// platform assets dir first, then the host PATH and the well-known distro game
// dirs, so either `scripts/fetch-assets.sh --arcade` (distro install) or a
// hand-built binary dropped in assets/bin makes the game appear in the menu.
type extGame struct {
id string // stable id; also the per-user save subdir under arcade/
label string // menu label
desc string // one-line menu description
bins []string // candidate binary names (first that resolves wins)
args []string // launch args (most need none)
}
// extGames is the arcade catalog of external classics, in menu order.
var extGames = []extGame{
{id: "invaders", label: "Space Invaders", desc: "nInvaders — shoot the descending alien fleet", bins: []string{"ninvaders", "nInvaders"}},
{id: "pacman", label: "Pac-Man", desc: "pacman4console — clear the maze, dodge the ghosts", bins: []string{"pacman4console"}},
{id: "tetris", label: "Tetris", desc: "tint — stack the falling tetrominoes", bins: []string{"tint", "vitetris", "tetris"}},
{id: "moonpatrol", label: "Moon Patrol", desc: "moon-buggy — jump the craters across the lunar surface", bins: []string{"moon-buggy"}},
}
// gameDirs are the well-known locations distro packages drop game binaries.
// Debian/Ubuntu put them in /usr/games, which is usually off the daemon's PATH,
// so we probe these explicitly in addition to exec.LookPath.
var gameDirs = []string{"/usr/games", "/usr/local/games", "/usr/local/bin", "/usr/bin"}
// entry is one row in the arcade menu.
type entry struct {
label string
desc string
run func(m *menu) (tea.Model, tea.Cmd)
section string
label string
desc string
run func(m *menu) (tea.Model, tea.Cmd)
}
type menu struct {
@ -45,42 +73,69 @@ type menu struct {
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"))
)
var theme = ui.New(ui.Gold)
func newMenu(user auth.User, ctx plugin.Context) *menu {
m := &menu{user: user, ctx: ctx}
// --- DOOM (per WAD) ---
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) },
section: "DOOM",
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 {
if doomBin(ctx) == "" {
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 },
section: "DOOM",
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 },
})
}
// --- arcade classics (external binaries) ---
var arcadeFound bool
for _, g := range extGames {
g := g
if resolveBin(ctx, g.bins) == "" {
continue
}
arcadeFound = true
m.entries = append(m.entries, entry{
section: "ARCADE",
label: g.label,
desc: g.desc,
run: func(m *menu) (tea.Model, tea.Cmd) { return m, m.launchExt(g) },
})
}
if !arcadeFound {
m.entries = append(m.entries, entry{
section: "ARCADE",
label: "Arcade classics — not installed",
desc: "run scripts/fetch-assets.sh --arcade on the host (Space Invaders, Pac-Man, Tetris, Moon Patrol)",
run: func(m *menu) (tea.Model, tea.Cmd) { m.note = "arcade binaries missing on host"; return m, nil },
})
}
// --- built-in (leaderboard-backed) ---
m.entries = append(m.entries,
entry{
label: "Snake",
desc: "built-in; high scores hit the global leaderboard",
section: "BUILT-IN",
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",
section: "BUILT-IN",
label: "Leaderboard",
desc: "global top scores",
run: func(m *menu) (tea.Model, tea.Cmd) {
m.child = newBoard(m.ctx)
return m, m.child.Init()
@ -116,24 +171,72 @@ func doomBin(ctx plugin.Context) string {
return ""
}
// resolveBin finds the first candidate binary that exists: bundled in the
// platform assets dir, on PATH, or in a well-known distro game dir.
func resolveBin(ctx plugin.Context, names []string) string {
for _, n := range names {
if p := filepath.Join(ctx.AssetsDir, "bin", n); isExec(p) {
return p
}
if p, err := exec.LookPath(n); err == nil {
return p
}
for _, d := range gameDirs {
if p := filepath.Join(d, n); isExec(p) {
return p
}
}
}
return ""
}
func isExec(p string) bool {
fi, err := os.Stat(p)
return err == nil && !fi.IsDir() && fi.Mode()&0o111 != 0
}
// workDir returns the writable per-game save dir: a stable path for members,
// a throwaway temp dir for guests.
func (m *menu) workDir(sub string) string {
if m.ctx.DataDir == "" {
d, _ := os.MkdirTemp("", "agentbbs-guest-"+strings.ReplaceAll(sub, "/", "-")+"-")
return d
}
d := filepath.Join(m.ctx.DataDir, "arcade", sub)
_ = os.MkdirAll(d, 0o755)
return d
}
// 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)
}
work := m.workDir(filepath.Join("doom", strings.TrimSuffix(filepath.Base(wad), filepath.Ext(wad))))
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}
return gameDoneMsg{name: "DOOM", err: err}
})
}
type doomDoneMsg struct{ err error }
// launchExt suspends the TUI and bridges the session to a sandboxed arcade
// classic on a real PTY (the generalized doom path).
func (m *menu) launchExt(g extGame) tea.Cmd {
bin := resolveBin(m.ctx, g.bins)
if bin == "" { // raced with an uninstall; surface rather than exec ""
m.note = g.label + " is no longer installed on the host"
return nil
}
work := m.workDir(g.id)
cmd := m.ctx.Sandbox.Command(work, bin, g.args...)
return tea.Exec(newPtyExec(cmd, m.width, m.height), func(err error) tea.Msg {
return gameDoneMsg{name: g.label, err: err}
})
}
type gameDoneMsg struct {
name string
err error
}
func (m *menu) Init() tea.Cmd { return nil }
@ -151,9 +254,9 @@ func (m *menu) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
switch msg := msg.(type) {
case doomDoneMsg:
case gameDoneMsg:
if msg.err != nil {
m.note = "doom exited: " + msg.err.Error()
m.note = msg.name + " exited: " + msg.err.Error()
}
return m, nil
case tea.KeyMsg:
@ -180,19 +283,23 @@ func (m *menu) View() string {
if m.child != nil {
return m.child.View()
}
s := tStyle.Render("Arcade") + "\n\n"
s := theme.Title("Arcade") + ui.Dim.Render(" · classic terminal games, sandboxed") + "\n\n"
prevSection := ""
for i, e := range m.entries {
cur := " "
if i == m.cursor {
cur = cStyle.Render("> ")
if e.section != prevSection {
if prevSection != "" {
s += "\n"
}
s += theme.Section(e.section) + "\n"
prevSection = e.section
}
s += fmt.Sprintf("%s%s\n %s\n", cur, e.label, dStyle.Render(e.desc))
s += theme.MenuItem(i == m.cursor, e.label, "", e.desc)
}
s += "\n" + dStyle.Render("↑/↓ move · enter play · q back to hub")
s += "\n" + ui.KeyBar("↑/↓ move · enter play · q back to hub")
if m.note != "" {
s += "\n" + eStyle.Render(m.note)
s += "\n" + ui.Danger.Render(m.note)
}
return lipgloss.NewStyle().Padding(1, 2).Render(s)
return ui.Frame.Render(s)
}
// backMsg returns from a child (snake/leaderboard) to the arcade menu.

View file

@ -4,10 +4,10 @@ import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/internal/ui"
)
// board renders the global top scores (PRD §5.1 leaderboards).
@ -42,17 +42,17 @@ func (b *board) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (b *board) View() string {
s := tStyle.Render("Leaderboard — snake") + "\n\n"
s := theme.Title("Leaderboard — snake") + "\n\n"
switch {
case b.err != nil:
s += eStyle.Render("error: " + b.err.Error())
s += ui.Danger.Render("error: " + b.err.Error())
case len(b.scores) == 0:
s += dStyle.Render("no scores yet — be the first")
s += ui.Dim.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)
s += "\n" + ui.KeyBar("any-key return to menu")
return ui.Frame.Render(s)
}

View file

@ -18,6 +18,7 @@ import (
"github.com/profullstack/agentbbs/internal/plugin"
qi "github.com/profullstack/agentbbs/internal/qryptinvite"
"github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/internal/ui"
)
// Plugin is the hub registration. It admits members only (guests have no
@ -99,13 +100,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m model) View() string {
return lipgloss.NewStyle().Padding(1, 2).Render(
m.body + "\n\n" + dStyle.Render("press any key to return"))
return ui.Frame.Render(m.body + "\n\n" + ui.KeyBar("esc/q return to menu"))
}
var (
hStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
dStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
urlStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#60a5fa"))
hStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
dStyle = ui.Dim
errStyle = ui.Danger
urlStyle = lipgloss.NewStyle().Foreground(ui.Blue)
)