diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 24ce2e6..c26221e 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -406,6 +406,9 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }() ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets, Host: a.host} + if pty, _, ok := s.Pty(); ok { + ctx.Term = pty.Term // ncurses arcade games need the client's TERM + } if u.Kind != auth.Guest { ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name) _ = os.MkdirAll(filepath.Join(ctx.DataDir, "wads"), 0o755) diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 7bbe300..05efeb6 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -25,6 +25,11 @@ type Context struct { // Host is the BBS hostname (e.g. bbs.profullstack.com), for building // member homepage URLs (https://Host/~name) and similar links. Host string + // Term is the client PTY's terminal type (e.g. xterm-256color). Needed by + // sandboxed ncurses games (Space Invaders, Pac-Man, Tetris, Moon Patrol), + // which call initscr() and fail with "Error opening terminal" if TERM is + // unset — the systemd daemon environment has no TERM to inherit. + Term string } // Plugin is the only integration point between a feature and the hub. diff --git a/plugins/arcade/arcade.go b/plugins/arcade/arcade.go index e5195d6..477b796 100644 --- a/plugins/arcade/arcade.go +++ b/plugins/arcade/arcade.go @@ -207,12 +207,32 @@ func (m *menu) workDir(sub string) string { return d } +// gameEnv is the minimal environment handed to a sandboxed game. It does NOT +// inherit the daemon's environment (which carries operator secrets like +// COINPAY_API_KEY) — a third-party game binary has no business seeing those. +// TERM comes from the client PTY so ncurses games (Space Invaders, Pac-Man, +// Tetris, Moon Patrol) can open the terminal; without it initscr() fails with +// "Error opening terminal" and the game exits before drawing a frame. +func (m *menu) gameEnv(work string) []string { + term := m.ctx.Term + if term == "" { + term = "xterm-256color" // sane default if the client didn't request a PTY type + } + return []string{ + "TERM=" + term, + "PATH=/usr/games:/usr/local/games:/usr/local/bin:/usr/bin:/bin", + "HOME=" + work, + "LANG=C.UTF-8", // unicode box-drawing for the ncurses games + } +} + // 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.workDir(filepath.Join("doom", strings.TrimSuffix(filepath.Base(wad), filepath.Ext(wad)))) cmd := m.ctx.Sandbox.Command(work, bin, "-iwad", wad) + cmd.Env = m.gameEnv(work) return tea.Exec(newPtyExec(cmd, m.width, m.height), func(err error) tea.Msg { return gameDoneMsg{name: "DOOM", err: err} }) @@ -228,6 +248,7 @@ func (m *menu) launchExt(g extGame) tea.Cmd { } work := m.workDir(g.id) cmd := m.ctx.Sandbox.Command(work, bin, g.args...) + cmd.Env = m.gameEnv(work) return tea.Exec(newPtyExec(cmd, m.width, m.height), func(err error) tea.Msg { return gameDoneMsg{name: g.label, err: err} })