diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..7425e4b --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,191 @@ +# StackPilot Roadmap — Phases 21–23 + +Planned 2026-06-09. Status keys: ☐ not started · ◐ in progress · ☑ done. +Each phase ships independently following the standing release checklist +(bump `backend/main.py` + `backend/agent_app.py` AGENT_VERSION + +`frontend/package.json` → build backend→agent→frontend `:VERSION`+`:latest` → +py_compile + `tsc -b && vite build` → route smoke-test → push all 3 → README → +git commit + push → update memory). Current released version: **0.26.0**. + +Order: **21 → 22 → 23** (Terminal is highest-value and self-contained; Secrets +is most design-ambiguous, left last). + +--- + +## Phase 21 — Container terminal (web exec) ☐ NOT STARTED → target 0.27.0 + +Interactive shell into a running, compose-managed container over WebSocket + +xterm.js, like Portainer's Console. Local + agent. **Admin-only** (exec is +root-equivalent). + +### Backend +- ☐ `services/exec_service.py` (new). Reuse `container_service._get_managed()` + to refuse non-compose containers. Functions: + - `create_exec(container_id, cmd, tty=True)` → low-level + `client.api.exec_create(container_id, cmd, stdin=True, tty=True, + stdout=True, stderr=True)` returns `exec_id`. + - `start_exec(exec_id)` → `client.api.exec_start(exec_id, socket=True, + tty=True, demux=False)` returns the raw socket (a `SocketIO`/`socket`; + on some docker-py versions the real fd is `sock._sock` — handle both; + **verify live**, this is the main risk). + - `resize_exec(exec_id, h, w)` → `client.api.exec_resize(exec_id, height=h, + width=w)`. + - default cmd = `["/bin/sh"]` (frontend may request `/bin/bash`). +- ☐ `routers/ws.py`: add `_authorize_admin(websocket, token)` (decode JWT like + `_authorize` but also require `role == "admin"`; close 4403 if not admin). + Then `@router.websocket("/ws/exec/{container_id}")`: + - accept, `_authorize_admin`, read `?cmd=` (default /bin/sh), create+start exec. + - **Bidirectional pump** with two asyncio tasks: + - reader: `loop.run_in_executor(None, sock.recv, 4096)` → `send_bytes`/ + `send_text` to browser (decode utf-8, errors="replace"). + - writer: `await websocket.receive_text()`; JSON control msgs + `{type:"resize",cols,rows}` → `resize_exec`; otherwise raw keystrokes → + `sock.sendall(data.encode())` (also in executor). + - on first task to finish, cancel the other; close sock + ws. + - audit `container.exec`. +- ☐ `agent_app.py`: `@app.websocket("/agent/ws/exec/{container_id}")` — token + via `?token=` vs AGENT_TOKEN, same pump (import exec_service). +- ☐ `routers/ws.py` proxy: `@router.websocket("/ws/agent-exec/{agent_id}/{container_id}")` + — mirror `ws_agent_deploy` BUT forward **both directions** (agent-logs/deploy + only pump upstream→browser; exec also needs browser→upstream). Use + `urllib.parse.quote(token, safe='')`, InvalidStatus 404 → "update agent" hint. + +### Frontend +- ☐ Add deps to `frontend/package.json`: `xterm` (`@xterm/xterm`) + + `@xterm/addon-fit`. (rebuild installs them). +- ☐ `components/stacks/ContainerTerminal.tsx` — modal: xterm.js Terminal + + FitAddon, connects to `/ws/exec/{id}` or `/ws/agent-exec/{aid}/{id}`; shell + `