"""Terminal WebSocket — attaches to persistent PTY sessions. The PTY lifecycle is managed by backend.sessions.SessionManager. WebSocket connections attach/detach without killing the PTY. """ import asyncio import logging from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query from backend.auth import ws_authenticate from backend.sessions import manager router = APIRouter(tags=["terminal"]) log = logging.getLogger("atlus.terminal") @router.websocket("/api/terminal/ws") async def terminal_ws( websocket: WebSocket, terminal_id: str = Query(default=None), ): """Attach a WebSocket to an existing persistent terminal. Query params: - terminal_id: required — the terminal to attach to - token: auth token (handled by ws_authenticate) Client sends: - {"type": "input", "data": "..."} — keystrokes - {"type": "resize", "cols": N, "rows": N} Server sends: - {"type": "output", "data": "..."} — terminal output - {"type": "scrollback", "data": "..."} — initial scrollback replay """ username = await ws_authenticate(websocket) await websocket.accept() if not terminal_id: await websocket.send_json({"type": "error", "data": "terminal_id required"}) await websocket.close(code=4000) return terminal = manager.get_terminal(username, terminal_id) if not terminal or not terminal.alive: await websocket.send_json({"type": "error", "data": "Terminal not found or dead"}) await websocket.close(code=4004) return # Send scrollback replay so the client sees previous output if terminal.scrollback: scrollback_text = "".join(terminal.scrollback) await websocket.send_json({"type": "scrollback", "data": scrollback_text}) # Attach this WebSocket to receive live output terminal.attach_ws(websocket) log.info("WebSocket attached to terminal %s for %s", terminal_id, username) try: while True: msg = await websocket.receive_json() msg_type = msg.get("type") if msg_type == "input": if terminal.alive: data = msg["data"] if isinstance(data, str): data = data.encode("utf-8") terminal.pty.write(data) elif msg_type == "resize": if terminal.alive: cols = msg.get("cols", 120) rows = msg.get("rows", 30) terminal.pty.setwinsize(rows, cols) except WebSocketDisconnect: pass except Exception: log.exception("terminal ws error for %s/%s", username, terminal_id) finally: terminal.detach_ws(websocket) log.info("WebSocket detached from terminal %s for %s", terminal_id, username) # PTY stays alive — no kill here