# StackPilot Roadmap — Phases 21–23 > **Historical record.** Phases below describe work as it shipped at the time. > The multi-host / agent integration they refer to was removed in 0.48.0 — > StackPilot manages a single Docker host. Anything here mentioning > `agent_app.py`, `AGENT_TOKEN`, `/api/agents/*` or `/ws/agent-*` no longer > exists; the entries are kept because they record what was actually done, not > what is currently true. 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.29.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) ☑ DONE — shipped 0.27.0 **Result:** All backend paths live-verified via TestClient against real containers — local exec (echo round-trip, exit code 0, unmanaged-guard, non-admin→4403, no-token→4401), agent exec (echo, bad-token→4401), and the central proxy (bidirectional: keystrokes browser→proxy→agent→container, output back). docker-py 7.1.0 socket is `socket.SocketIO` with the real fd at `._sock` (as predicted). Frontend `tsc -b && vite build` green with xterm. Not click-tested in a real browser (the WS/exec plumbing is what's verified). 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 `