270 lines
10 KiB
Markdown
270 lines
10 KiB
Markdown
# 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-<session_id> (UUID, sanitized — alphanumeric + hyphen)
|
|
tmux windows: term-<pane_id> (one window per terminal pane)
|
|
```
|
|
|
|
booterm spawns `tmux new-session -d -s bc-<sid> -c <project_root>` lazily on first attach. Subsequent attaches do `tmux new-window -t bc-<sid>` for additional panes, or `tmux attach -t bc-<sid>` 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-<pid>"}` |
|
|
| 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 <root>` 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 `<text>\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.
|