# BooCode v1.1 — Batch 10 **Theme:** BooTerm. Second container, dedicated to in-browser terminals. Per-session tmux. xterm.js + node-pty in-container. New pane type wires into the BooCode shell. **Status:** Planned. Largest batch in v1.1. Depends on Batch 3 (pane system), Batch 7 (settings drawer pattern reused). **Repo:** `/opt/boocode/` (shared monorepo). New `apps/booterm/` subdirectory. ## Goals 1. New container `booterm` running Fastify + node-pty + tmux. Per-session tmux session keyed by `(user, session_id)`. 2. xterm.js terminal pane in the BooCode shell. Multiple terminal panes per session, each attached to a separate tmux window. 3. PTY traffic over WebSocket. Auth via `Remote-User`. 4. tmux as session manager so terminals survive WebSocket reconnects, page refreshes, even container restarts. 5. Read+write capability scoped to project root. No `cd ..` escape. 6. Path-based routing: `code.indifferentketchup.com/api/term/*` → booterm; `/ws/term/*` → booterm. ## Architecture ``` browser ──HTTPS──> Caddy (droplet) ──Tailscale──> Authelia │ ├── /api/chat/*, /ws/chat/* → boocode :9500 ├── /api/term/*, /ws/term/* → booterm :9501 └── / → boocode (SPA) booterm container: - Fastify (Node 20) - node-pty - tmux installed in container (apk add tmux) - same Postgres (boocode_db) - mounts projects rw (scoped) ``` ### Mount strategy Decided: Option A. Per-project bind mounts in `docker-compose.yml`. Already applied: booterm has `/opt:/opt:rw` to keep parity with the existing boocode mount and avoid enumerating roots. Project root for any given session derives from `projects.root_path` and tmux launches with `cwd` set there. ### tmux session naming Per-session tmux: ``` tmux session name: bc- (UUID, sanitized — alphanumeric + hyphen) tmux windows: term- (one window per terminal pane) ``` booterm spawns `tmux new-session -d -s bc- -c ` lazily on first attach. Subsequent attaches do `tmux new-window -t bc-` for additional panes, or `tmux attach -t bc-` and select window. ## Data model | Column | On | Type | Default | Notes | |---|---|---|---|---| | (none) | — | — | — | terminals are tmux-managed, no DB rows | | `kind = 'terminal'` | `session_panes.kind` CHECK | — | — | Extend CHECK to include `'terminal'` | | `state.tmux_window` | `session_panes.state` JSONB | TEXT | NULL | Which tmux window this pane attaches to | Schema (already applied to live DB + schema.sql): ```sql ALTER TABLE session_panes DROP CONSTRAINT IF EXISTS session_panes_kind_check; ALTER TABLE session_panes ADD CONSTRAINT session_panes_kind_check CHECK (kind IN ('chat', 'file_browser', 'terminal')); ``` ## Backend (booterm) New app at `apps/booterm/`: ``` apps/booterm/ ├── src/ │ ├── index.ts # Fastify + WS + auth │ ├── auth.ts # Remote-User middleware (same pattern as boocode) │ ├── db.ts # pg pool (shared boocode_db) │ ├── routes/ │ │ ├── health.ts │ │ └── terminals.ts # POST /api/term/sessions/:sid/panes/:pid/start (creates tmux window) │ ├── pty/ │ │ ├── manager.ts # tmux process management │ │ └── pty.ts # node-pty wrapper for `tmux attach -t ... -d` │ └── ws/ │ └── attach.ts # WS /ws/term/sessions/:sid/panes/:pid → PTY bidi pipe ├── package.json └── tsconfig.json ``` ### Endpoints | Method | Path | Notes | |---|---|---| | GET | `/api/term/health` | Ping | | POST | `/api/term/sessions/:sid/panes/:pid/start` | Idempotent tmux window create. Returns `{tmux_window: "term-"}` | | WS | `/ws/term/sessions/:sid/panes/:pid` | Attach PTY | | POST | `/api/term/sessions/:sid/panes/:pid/resize` | `{cols, rows}` | | POST | `/api/term/sessions/:sid/panes/:pid/kill` | Kill the tmux window | WS frames (binary or text): ``` client → server: pty input (raw bytes, typed by user) server → client: pty output (raw bytes from shell) server → client: {type: "exit", code} on window close ``` ### Auth + scoping - `Remote-User` required on WS upgrade. - `session_id` validated: lookup in `sessions` table; require row exists. - `pane_id` validated: must exist in `session_panes` with `kind = 'terminal'` and matching `session_id`. - Project root derived from `sessions.project_id → projects.root_path`. tmux starts `cd ` in that dir. **No chroot.** User can `cd /` and read anything mounted into the container. - Future hardening: namespace/chroot. Out of v1.1 scope. ### tmux config `apps/booterm/tmux.conf` bundled into image at `/etc/booterm/tmux.conf`; tmux invocations use `-f /etc/booterm/tmux.conf`: ``` set -g default-terminal "screen-256color" set -g history-limit 50000 set -g mouse on setw -g mode-keys vi set -g status off set -g destroy-unattached off ``` Boolab pattern (from `services/tmux_session.py`). ## Frontend | File | Change | |---|---| | `apps/web/src/components/panes/TerminalPane.tsx` (NEW) | xterm.js mount, WS attach, resize handler | | `apps/web/src/api/client.ts` | `api.terminals.start(sessionId, paneId)`, `api.terminals.resize(...)`, `api.terminals.kill(...)` | | `apps/web/src/components/Workspace.tsx` | Add 'terminal' to the pane kind enum; spawn button → POST start → render TerminalPane. Tab UI lives in Workspace.tsx — there is no PaneTab.tsx file. | | `apps/web/package.json` | `xterm` + `xterm-addon-fit` + `xterm-addon-web-links` | ### TerminalPane ```tsx useEffect(() => { const term = new Terminal({ fontFamily: 'JetBrains Mono', fontSize: 14, theme: ... }); const fit = new FitAddon(); term.loadAddon(fit); term.loadAddon(new WebLinksAddon()); term.open(containerRef.current); fit.fit(); const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(`${proto}//${window.location.host}/ws/term/sessions/${sid}/panes/${pid}`); ws.binaryType = 'arraybuffer'; ws.onmessage = e => term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data)); term.onData(data => ws.send(data)); term.onResize(({ cols, rows }) => api.terminals.resize(sid, pid, cols, rows)); const ro = new ResizeObserver(() => fit.fit()); ro.observe(containerRef.current); return () => { ws.close(); term.dispose(); ro.disconnect(); }; }, [sid, pid]); ``` Dev: vite.config.ts needs `/api/term` and `/ws/term` proxy entries mirroring the existing `/api` and `/ws` ones. ## Send-to-terminal from chat Boolab pattern: select text in a message → "Send to terminal" button → text becomes terminal input. - Right-click context menu on selected text in chat → "Send to terminal" submenu lists open terminal panes. - Click target → sends `\n` to that pane's WS. Implementation: | File | Change | |---|---| | `apps/web/src/components/MessageBubble.tsx` | Selection handler + context menu | | `apps/web/src/lib/events.ts` | New event `send_to_terminal` with payload `{pane_id, text}` | | `apps/web/src/components/panes/TerminalPane.tsx` | Subscribe to event for its `pane_id`, write to WS | ## Docker compose (already applied) booterm service is already in `docker-compose.yml` with: - build context `.`, dockerfile `apps/booterm/Dockerfile` - port `100.114.205.53:9501:3000` - `/opt:/opt:rw` mount - `DATABASE_URL` env pointing at `boocode_db` - `boocode_net` network - depends_on: `boocode_db` Do not re-edit compose. ## Backend dependencies `apps/booterm/package.json`: - `fastify` - `@fastify/websocket` - `pg` - `zod` - `node-pty` - `tslib` `node-pty` requires native build. Dockerfile installs `python3 make g++` in build stage and `tmux` in runtime stage: ```dockerfile FROM node:20-alpine AS build RUN apk add --no-cache python3 make g++ tmux WORKDIR /app COPY ... RUN pnpm install --frozen-lockfile && pnpm build FROM node:20-alpine RUN apk add --no-cache tmux WORKDIR /app COPY --from=build /app/apps/booterm/dist ./dist COPY --from=build /app/node_modules ./node_modules EXPOSE 3000 CMD ["node", "dist/index.js"] ``` ## Files to touch **New app:** - `apps/booterm/` (entire subtree) **Existing changes:** - `apps/web/package.json` - `apps/web/src/api/client.ts` - `apps/web/src/api/types.ts` - `apps/web/src/components/Workspace.tsx` - `apps/web/src/components/MessageBubble.tsx` - `apps/web/src/components/panes/TerminalPane.tsx` (NEW) - `apps/web/src/lib/events.ts` - `apps/web/vite.config.ts` (proxy entries) **Already done by user — do not touch:** - `docker-compose.yml` (booterm service added) - `apps/server/src/schema.sql` (terminal CHECK constraint) - Live DB constraint applied ## Verification 1. `docker compose up -d --build booterm` → container healthy. 2. `curl -s http://100.114.205.53:9501/api/term/health -H 'Remote-User: sam'` → 200. 3. Browser smoke test: - Open a session. Workspace → "+ Terminal" → terminal pane appears with shell prompt in project root. - Type `ls -la` → output. - Type `vim test.txt`, write something, save, `:q` → file exists on host (since rw mount). - Refresh browser → terminal reconnects, history intact (tmux persistence). - Open second terminal pane → same project, separate tmux window. Both work independently. - Select code in chat → right-click → "Send to terminal" → terminal pane receives the text. - Container restart (`docker compose restart booterm`) → on reconnect, tmux session resumes from where it left off. - Close pane via tab context menu → tmux window killed. Reopen pane → fresh shell. ## Constraints - node-pty is a native dep. Image size grows. - tmux history capped at 50k lines per window. - WebSocket frames are bidirectional binary; `binaryType = 'arraybuffer'`. - Resize debounced 100ms client-side; backend `tmux resize-window` per resize. - No chroot/namespace isolation in v1.1. User has full read+write under `/opt/`. Acceptable for single-user homelab. - Don't expose 9501 on 0.0.0.0. Tailscale binding only (already configured in compose). ## Open - Color theme matching for xterm.js. Defer. - File-drop into terminal (upload via terminal pane). Out of scope. - Multi-user (each user gets own tmux server) — defer until BooCode goes multi-user, which isn't planned. - BooCoder container — same skeleton as booterm but with edit_file / create_file tools instead of PTY. Will follow this pattern when built.