* 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>
* fix(deploy): build Go binaries on the runner, ship them, SKIP_BUILD on box
The deploy SSHed into the ~458MB droplet and ran `go build` there. The Go
linker's peak memory OOM-killed the build — and with it the sshd serving the
deploy session — surfacing as "Connection closed by remote host" (exit 255).
It was flaky because it tracked momentary memory pressure from the co-resident
ergo/forgejo/tor/podman/agentbbs processes (run #25 passed, #26 failed on
near-identical code).
Build both binaries on the 16GB GitHub runner instead (pure-Go, modernc
sqlite, so CGO_ENABLED=0 static cross-build), scp them to the droplet, and run
setup.sh with SKIP_BUILD=1 so the box never compiles. Arch is detected from
the droplet so amd64/arm64 both work. setup.sh now also skips the Go toolchain
download when SKIP_BUILD=1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(members): member directory + store-and-forward messaging
A members-only hub plugin (the BBS "who") plus user-to-user messaging:
- internal/store: messages table + SendMessage/Inbox/UnreadCount/MarkRead, and
OnlineUsers (open sessions) for presence. MarkRead is recipient-scoped so a
member can only clear their own mail.
- plugins/members: directory with online dots + last-seen, a finger-style
profile view, a minimal compose box, and an inbox that marks read on open.
- ssh msg@host <user> [text]: scriptable CLI to leave a note (body from args or
stdin), mirroring the existing finger route; "msg"/"message" are reserved.
- hub: "N unread" badge on login (hubMOTD). plugin.Context gains Host for member
homepage URLs.
Extends the existing finger@ behavior (ssh <name>@host) rather than replacing it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The box auto-deploys (self-update timer pulls origin/main, rebuilds, restarts
agentbbs), but `systemctl restart agentbbs` only restarts the daemon — it never
touches the long-lived per-user pod containers. ensure() also short-circuits on
any container that already exists, so pods created before the homepage bind
landed would never gain the /home/dev/public_html mount without a manual
`podman rm`. That defeats the "everything happens automatically on push" goal.
Make ensure() self-healing: when a pod exists but lacks the public_html mount,
recreate it so the bind is applied. The named home volume survives `rm`, so the
member's files are kept. Only heal when the pod is idle (attached count 0) to
avoid pulling a running pod out from under an active session — an unbound pod
heals on its next idle attach. New hasMount() inspects the container's mounts.
Net effect: push to main -> self-update redeploys within the timer interval ->
the next `ssh pod@` recreates the pod with the bind. No manual step.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A member's pod home (/home/dev) is a named container volume, but Caddy serves
<name>.<host> from the host path <data>/users/<name>/public_html. The two were
disconnected, so editing ~/public_html/index.html in the pod never changed the
served page — contradicting the on-screen "edit ~/public_html to make it yours"
instruction.
Bind-mount <data>/users/<user>/public_html at /home/dev/public_html when pods
start. The host tree is created if absent so the mount source exists, and under
the docker fallback (uid 1000) the one-shot init container now also chowns the
bind path; rootless podman maps container root to the host service user that
already owns the tree, so no chown is needed there.
Detect now takes the host users dir; pass "" to disable the bind.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
flipLastChar toggled only the last base64 char of the signature segment.
For a 64-byte Ed25519 signature that char carries unused trailing bits, so
the toggle could decode back to the same bytes and still verify — making the
"corrupted signature must fail" assertion flaky (it reddened main CI).
Corrupt a decoded signature byte (sig[0] ^= 0xFF) and re-encode instead, so
the signature always differs. Verified deterministic over 50 runs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the figlet "profullstack.com" wordmark with a scaled-down ASCII
rendition of the Profullstack </> mark (derived from the brand image),
shown on the join@ onboarding and the ssh <name>@ hub. Reuses the existing
banner plumbing; the mark is sourced from internal/brand and rendered in
Profullstack red.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework the IRC access model to the user-level source of truth — the
bbs.profullstack.com user store — per the agreed design:
- Auth via the store, not OS users. New loopback endpoint /irc-auth (on the
/verify server) answers {member,premium} from the store via UserByName. Ergo's
auth-script now curls it instead of `getent passwd`; the OS-user (tilde.town)
reconcile in setup.sh is reverted. require-sasl on, registration off.
- Remove the in-BBS `ssh irc@` route: delete internal/irc (in-process client +
TUI), handleIRC/runIRC, the hub IRC menu entry, and the dispatch case. Members
connect with an external client (or web) at irc.profullstack.com:6697 / wss.
IRCNames kept only to reserve the "irc" name (IsIRCName removed).
- Keep external clients; keep the irc.profullstack.com hostname + cert work.
Premium channel creation: /irc-auth already returns each account's premium
status, but enforcement is NOT yet wired (the old irc@ /create was removed with
the route; Ergo can't gate creation per-account natively). operator-only-creation
stays off for now; server-side premium gating (a ChanServ-style bot) is a
documented follow-up. See docs/irc.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UX pass on the single-login hub:
- ctrl+c now always returns to the main menu from any in-hub plugin/game
instead of being forwarded. Fixes the snake game being un-exitable (it never
handled ctrl+c) and makes "back to menu" uniform across every screen.
- profullstack.com ASCII banner rendered atop the hub menu and the join@
onboarding flow.
- MOTD box under the title (guest vs member tailored; AGENTBBS_MOTD overrides
the body).
- New "Shell" hub entry drops straight into `bash -l` in the member's pod; the
existing "Pod" entry now attaches to the pod's main session.
- Polish: highlighted selection + ❯ cursor, brand colors, clearer footer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hostname: serve the network as irc.profullstack.com (new IRC_DOMAIN var,
default irc.<root-of-DOMAIN>). Caddy serves an irc.profullstack.com site so it
gets a Let's Encrypt cert; ergo-refresh-certs copies that into Ergo for 6697.
Needs an A record irc.profullstack.com -> the box (self-signed until it resolves).
Members are OS users (tilde.town model): setup.sh reconciles a real OS account
per member dir (root-side, on each deploy + the 15-min timer; nologin shell, so
identity-only — no shell access). The IRC auth-script now gates on
`getent passwd` with uid>=1000 instead of the member dir, so "OS user" == member.
Premium channels: free members may /join; creating channels is a premium perk.
The ssh irc@ client gains /create #name (premium-gated via ensurePremium): it
joins the fresh channel and registers it with ChanServ as the member's founder.
v1 gate is route-level (operator-only-creation left off); external-client
creation hardening is a follow-up. See docs/irc.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The join@ output and hub previously pushed members to ssh into separate
servers (ssh pod@, ssh irc@, ssh news@, ssh tor@). Members now reach
everything from one `ssh <name>@bbs.profullstack.com` login:
- hub: new SessionApp entries (Pod, IRC, News, Tor shell) run as
terminal-takeover features via tea.Exec, then return to the menu.
Gated by membership/email-verification/plan; shown locked otherwise.
- main: build the session apps in teaHandler; extract runIRC/runNews so
the irc@/news@ routes and the hub share one path. The old pod@/irc@/
news@/tor@ routes stay as aliases (handy for bots).
- onboarding: rewrite the "You're in" and Founding-Member copy to present
ONE login and stop advertising separate servers.
- email: member mailboxes move to mail.profullstack.com (apex reserved
for corporate mail); webmail shown at https://mail.profullstack.com.
- username: harden the default handle to a safe /home/<name> token.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the $10 "Premium" pitch with a $99 Founding Lifetime Member
offer (first 1,000 accounts) and list every bonus feature in the join@
upgrade prompt: personal email + webmail, custom domains, Tor access,
and lifetime locked-in pricing.
- payments: PremiumPriceLabel/default amount $10 -> $99, add FoundingCap
- join@ offer + already-paid welcome + gated-route upsell copy
- README + interface doc comments
Price stays env-overridable via AGENTBBS_PREMIUM_AMOUNT.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings the in-process IRC client (internal/irc) and the `irc@` SSH route on
top of the already-merged members-only Ergo server, completing the IRC
feature. The other commits on the branch (Ergo server, Tor routes) were
already merged via feat/qrypt-invite-issuer, so this applies only the client.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The live CoinPay API returns crypto_amount as a bare JSON number (e.g.
0.0031), but the struct decoded it as a string, so /payments/create failed
with "cannot unmarshal number into ... crypto_amount of type string" and
join@ showed "Payment is temporarily unavailable". The unit test had hidden
the bug by sending the value quoted.
Add a flexStr type that unmarshals from either a JSON number or string and
use it for crypto_amount; update the test to send a number and add a direct
flexStr decode test for both forms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an in-process IRC client (internal/irc) and an `irc@` SSH route that
drops a member straight into the BBS's own Ergo network with no client to
install and no SASL to configure.
- internal/irc/client.go: minimal IRC client (SASL PLAIN, IRCv3 CAP, PING,
PRIVMSG/JOIN/PART/NICK, event stream). Dials Ergo on the loopback
127.0.0.1:6667; presents the member's account name (the SSH key already
proved membership; Ergo's auth-script ignores the passphrase by design).
- internal/irc/tui.go: Bubble Tea TUI over the SSH PTY (mirrors internal/chat)
with /join /part /msg /me /names /nick /help and a current-channel input.
- cmd/agentbbs: handleIRC resolves the member by key (members-only, free) and
runs the client; routed via auth.IsIRCName. AGENTBBS_IRC_ADDR overrides the
target on dev hosts.
- auth: reserve `irc` as a route name.
Unlike copying tor-irc@ (a third-party client in a pod), this runs our own Go
code in-process, so there is no /exec shell-escape surface, and the host
process can reach Ergo's loopback listener directly.
Validated live against a members-only Ergo: non-members are rejected, a member
authenticates via SASL, and channel messages are received.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add three premium-gated SSH routes:
ssh tor-url@host <url> one-shot HTTP(S) GET over Tor (host-side,
curl via SOCKS, 30s/2MB caps, http/https only)
ssh -t tor-irc@host <server> interactive IRC over Tor in the member's pod
ssh -t tor@host <command...> run any command over Tor (torsocks) in the pod
tor-url runs host-side and constrained; tor/tor-irc run inside the member's
isolated pod (new pods.Exec) so arbitrary/interactive commands are sandboxed,
never on the host. internal/tor wraps curl/torsocks/irssi. All gated by
ensurePremium; names reserved. setup.sh installs + enables tor (SOCKS
127.0.0.1:9050) and torsocks.
Note: tor-url is host-side and self-contained. tor/tor-irc still need the pod
image to carry torsocks+irssi and reach the Tor SOCKS — follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Onboarding hard-coded the account name to member-<fp8>, so everyone got an
unmemorable handle like member-zafztqdk for ssh <name>@host and /~<name>.
New keys are now prompted for a username during join@. auth.SanitizeUsername
folds input to the hub/subdomain charset (lowercase [a-z0-9-], 3–20 chars,
'_'/space -> '-', no doubled/edge dashes); auth.IsReservedName blocks route and
infra labels (bbs/join/pod/domain/admin/agent/video/video-*/www/...). The name
must be free (UserByName) or we re-prompt; pressing enter keeps the member-<fp8>
default. Returning keys keep the name they already chose.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Gym-style game engine (PRD §5.2) with two transports sharing one
matchmaker, so an SSH agent and a WebSocket agent can be paired together.
Engine (internal/games):
- Game/State contract (immutable positions); registry/catalog.
- Phase-1 games: Tic-Tac-Toe (ttt) and Connect 4 (c4).
- ELO (K=32, start 1500), a generic win/block/random GreedyBot.
- Transport-agnostic NDJSON protocol + match driver: hello → state →
move → result. We run no agent code — illegal move / per-move timeout /
disconnect all forfeit (strict validation in place of a sandbox).
- Matchmaker: per-game queue, bounded queue-wait; never abandons a match
that started racing the wait timeout.
Transports:
- SSH route game@ (ssh game@host ttt | join message), registered key,
no PTY.
- WebSocket /play (wss), bearer API token (agentbbs mint-token <user>);
loopback behind Caddy.
Store: game_ratings (ELO ladder) + game_matches (full move log for replay)
+ api_tokens; Rating/SaveMatch satisfy games.Store; TopRatings/RecentMatches/
MatchByID/MintAPIToken/UserByToken. Banned accounts blocked.
Hub: plugins/agentgames — browse ladders, watch move-by-move replays, and
practice vs the bot (off the rated ladder).
Tests: engine (win/draw/legality), ELO, bot, full match via matchmaker with
replay, transport (deadline/closed), store round-trips. Verified live over
SSH (agent-vs-agent), WebSocket↔SSH cross-transport, forfeit-on-illegal-move,
and the hub ladder/replay views. Docs in docs/agentgames.md (the canonical
protocol spec, to mirror to logicsrc.com); README M3 → done.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A privileged operator console reached as `ssh admin@host`, gated by route
plus the $AGENTBBS_ADMINS allowlist (admin status is operator-granted only,
never self-assigned in-band). It is a self-contained Bubble Tea model, not a
hub plugin, so it never appears in the public menu.
Sections (PRD §6):
- Users & members: list accounts; b = suspend/ban (operators protected).
Banned accounts are blocked at the hub and pod@ routes.
- Sessions & pods: live in-memory session registry; k = disconnect.
- Moderation & audit: admin action log + agent@ transcripts (tab to switch).
- Config & plugins: runtime snapshot; space = enable/disable a plugin
(persisted; filtered from the hub on next sign-in).
Every privileged action is written to a new admin_actions audit table.
store: + banned column, admin_actions and plugin_state tables, and the
backing methods (ListUsers/SetBanned/RecentSessions/LogAdminAction/
RecentAdminActions/RecentChatsAll/DisabledPlugins/SetPluginDisabled), with
unit tests. auth: admin allowlist helpers + tests. Docs in docs/admin.md;
README M2 flipped to done.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- sites.AskHandler now approves <name>.AGENTBBS_HOST on-demand-TLS when <name>
is a registered member (so only real users mint subdomain certs).
- Caddyfile: *.${DOMAIN} block serving users/<name>/public_html with on_demand
TLS; unknown users 404. Needs a wildcard DNS record *.${DOMAIN} -> host.
- Test: TestAskUserSubdomain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CreatePremiumCharge/VerifyPremium now POST /payments/create and GET
/payments/:id against the CoinPay API (Bearer COINPAY_API_KEY, business_id =
AGENTBBS_COINPAY_MERCHANT_ID), so the droplet needs no coinpay CLI installed.
The created payment id is stored (store: User.PremiumPayID + premium_pay_id col
+ SetPremiumPayment); ensurePremium verifies that id on a later connect and
grants premium on confirmed/forwarded status. Removed the CLI command-template
env knobs; added AGENTBBS_COINPAY_API_URL. Tests: httptest-backed payments_test
+ store SetPremiumPayment test. Build/vet/gofmt/test green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- payments: AGENTBBS_COINPAY_MERCHANT_ID -> --business-id on the premium
create/pay commands (coinpay CLI reads COINPAY_API_KEY from env for auth).
- deploy.yml: forward COINPAY_API_KEY + COINPAY_MERCHANT_ID (GitHub secrets,
masked in logs) to the remote setup.sh.
- setup.sh: idempotent upsert_env writes those secrets into agentbbs.env
(COINPAY_API_KEY, AGENTBBS_COINPAY_MERCHANT_ID, COINPAY_BUSINESS_ID),
preserving the rest. No secret values are committed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deploy automation (idempotent, runs on every deploy):
- .github/workflows/deploy.yml: push to main/master (or dispatch) SSHes to the
droplet and re-runs setup.sh; deploys the pushed branch; smoke-tests :22.
- scripts/self-update.sh + agentbbs-update.timer: autonomous backstop that
redeploys only when origin advances.
- setup.sh hardened: flock, fetch+reset (survives force-push), fixed the
always-skipped arcade asset fetch path.
Membership model:
- Free, email-verified members get their own Docker pod (pod@ paywall removed)
and a /~name homepage (seeded at join@).
- join@ is now interactive: email -> emailed 6-digit code -> enter code.
- Premium ($10 one-time, lifetime via CoinPay) grants a personal
<name>@host email (new internal/forwardemail; forwardemail.net aliases) and
custom domains (domain@ gated to Premium).
- ensurePremium() silently verifies/grants/provisions on hub login, join@, and
domain@. New-signup details emailed to AGENTBBS_SIGNUP_NOTIFY (subject "bbs").
Store: User.Premium + premium/premium_ref cols, ConfirmEmailCode, GrantPremium.
Tests: store_premium_test.go, forwardemail_test.go. Build/vet/gofmt/test green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- setup.sh: idempotent one-shot droplet provisioner — agentbbs on :22
(admin OpenSSH moved to :2202), rootless podman, Caddy front end for
https://bbs.profullstack.com with tilde-style /~user homepages
- internal/sites + domain@ SSH route: self-service custom domains
(ssh domain@host add example.com) backed by a symlink farm and an
on-demand-TLS ask endpoint so Caddy only issues certs for mapped hosts
- internal/mail + join@ email verification: optional email at signup,
confirmation link served by a loopback /verify endpoint behind Caddy
- internal/source + cmd/ascii-live: live video → terminal ASCII groundwork
(docs/ascii-live.md)
- store: additive sqlite migrations (email/verify columns, domains table)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ssh video-<code>@ joins a PairUX/LiveKit call rendered as truecolor
ASCII (▀ half-blocks, 2 pixels per cell); video@ prompts for a code.
Codes are minted by PairUX only. Pipeline: VP8 RTP → PLI keyframe
requests → ivfwriter remux → ffmpeg decode/scale → RGB24 → ANSI →
bubbletea over the SSH PTY. Subscriber-only, no audio in v1.
ssh agent@ opens a persisted chat with the operator's agent —
AGENTBBS_AGENT_CMD runs per message (stdin→stdout), e.g. `claude -p`.
ssh <member>@ with someone else's name prints a classic finger card
(.plan, member since, last seen) and disconnects; your own name still
enters the hub.
cmd/lkpublish: dev publisher for testing (explicit -fps pacing; lksdk
IVF replay mispaces from file timebase alone, measured 1fps from a
15fps file; dimensions required or dynacast pauses the track).
Verified end-to-end against livekit-server --dev: 128k truecolor cells
/ 20k distinct colors streamed over a real SSH session; chat round-trip
and finger card verified over SSH. Go toolchain pinned to 1.26 via
mise.toml (lksdk requirement).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>