import type { IncomingMessage, Server as HttpServer } from 'node:http'; import type { Server as HttpsServer } from 'node:https'; import type { Socket } from 'node:net'; import { WebSocketServer, type WebSocket } from 'ws'; import { Router, json, type Request, type Response } from 'express'; import { logger } from '../logger.js'; import type { SessionRegistry } from '../ssh/console-registry.js'; import type { ConsoleSession } from '../ssh/console-session.js'; import type { AttachMessage, ServerTextMessage } from '../ssh/console-protocol.js'; import { checkConsoleInput } from '../ssh/console-deny-check.js'; import type { OpenConsoleDeps, OpenConsoleResult } from '../engine/tools/ssh-console.js'; import { openConsoleSession } from '../engine/tools/ssh-console.js'; export interface SimpleUser { id: string; role: 'admin' | 'user' | string } export interface SimpleTask { id: string; ownerId: string; visibility: string; pieceName: string } /** * Resolve the request user for the Console REST endpoints. In no-auth * single-user mode (`authActive === false`) there is no `req.user`, so a * stable `local` admin user is synthesized — admin role is required for the * no-auth task (owner_id NULL) to pass `buildVisibilityWhere` in resolveTask. * Mirrors the WS upgrade path and notifications-api's no-auth synthetic user. */ function resolveConsoleUser(req: Request, authActive: boolean): SimpleUser | null { const u = (req.user as SimpleUser | undefined) ?? null; if (u) return u; if (!authActive) return { id: 'local', role: 'admin', orgIds: [] } as SimpleUser & { orgIds: string[] }; return null; } export type AccessDecision = | { allowed: true; canWrite: boolean } | { allowed: false; reason: 'unauthenticated' | 'task_not_visible' | 'no_session' | 'no_grant' }; /** * Pure access decision for an SSH Console WS attach attempt. * * - Unauthenticated → reject * - Task not visible → reject ("not found"-like) * - No active session for the task → reject * - SSH access denied (no grant) → reject * - Otherwise → allow; canWrite gated on owner OR admin. * * Non-owners with task-visibility (e.g. org members on an org-visible task) * attach as read-only viewers — they see scrollback + live output but cannot * type or resize. */ export function decideAccess(args: { user: SimpleUser | null; task: SimpleTask | null; session: ConsoleSession | null; accessAllowed: boolean; }): AccessDecision { if (!args.user) return { allowed: false, reason: 'unauthenticated' }; if (!args.task) return { allowed: false, reason: 'task_not_visible' }; if (!args.session) return { allowed: false, reason: 'no_session' }; if (!args.accessAllowed) return { allowed: false, reason: 'no_grant' }; const canWrite = args.user.id === args.task.ownerId || args.user.role === 'admin'; return { allowed: true, canWrite }; } export interface DenyPatternProvider { /** Returns the {deny, allow} regex patterns for a given connection_id. */ getPatterns(connectionId: string): Promise<{ deny: string[]; allow: string[] }>; } export interface ConsoleWsDeps { registry: SessionRegistry; resolveUserFromUpgrade: (req: IncomingMessage) => Promise; resolveTask: (taskId: string, user: SimpleUser) => Promise; resolveSshAccess: (user: SimpleUser, session: ConsoleSession, task: SimpleTask) => Promise; denyPatterns: DenyPatternProvider; } const PATH_RE = /^\/+api\/local\/tasks\/([^/]+)\/console\/ws$/; /** * Attach the SSH Console WebSocket upgrade handler to the given http.Server. * * Matches paths of the form /api/local/tasks/:taskId/console/ws and runs the * full auth + access pipeline. Rejected upgrades are silently destroyed * (the client gets a 1006 abnormal close) so we don't leak failure * reasons over the upgrade channel. The reason is always logged. */ // Both http.Server and https.Server emit the 'upgrade' event used for WSS, // so either type is a valid host for the console WebSocket upgrade handler. export function attachConsoleWs(server: HttpServer | HttpsServer, deps: ConsoleWsDeps): void { const wss = new WebSocketServer({ noServer: true }); server.on('upgrade', async (req, socket, head) => { const url = req.url ?? ''; const m = url.match(PATH_RE); if (!m) return; const taskId = decodeURIComponent(m[1]!); try { const user = await deps.resolveUserFromUpgrade(req); const task = user ? await deps.resolveTask(taskId, user) : null; const session = deps.registry.get(taskId); const accessAllowed = !!(user && task && session) ? await deps.resolveSshAccess(user, session, task) : false; const decision = decideAccess({ user: user ?? null, task, session, accessAllowed }); if (!decision.allowed) { logger.info(`[console-ws] reject taskId=${taskId} reason=${decision.reason}`); socket.destroy(); return; } const patterns = await deps.denyPatterns.getPatterns(session!.connectionId); wss.handleUpgrade(req, socket as Socket, head, (ws) => { handleConsoleSocket(ws, session!, user!, decision.canWrite, patterns); }); } catch (e) { logger.warn(`[console-ws] upgrade error: ${(e as Error).message}`); socket.destroy(); } }); } /** * Handle a single accepted Console WebSocket. The caller has already done * auth + access + scrollback fetch. * * Wire protocol (see console-protocol.ts): * - server → client: attach (JSON), ESC c (binary reset), replay bytes, * replay_begin (JSON), replay_end (JSON), live binary output, notices * - client → server: binary input frames (forwarded to PTY) and JSON * control frames (`resize`). * * Input policy: * - canWrite=false ⇒ all input is rejected with a 'warn' notice. * - canWrite=true ⇒ input is forwarded as-is to the PTY, BUT any chunk * containing a line terminator is first checked against the connection's * deny/allow patterns. A rejected line drops the WHOLE chunk and emits * an 'error' notice. The check fires only on chunks containing a CR/LF * since pre-Enter keystrokes are partial input the operator hasn't * committed yet — the live shell echo will show them on screen but * they only matter for safety once the line is submitted. */ export function handleConsoleSocket( ws: WebSocket, session: ConsoleSession, user: SimpleUser, canWrite: boolean, patterns: { deny: string[]; allow: string[] }, ): void { const sendText = (msg: ServerTextMessage) => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg)); }; const sendBinary = (buf: Buffer) => { if (ws.readyState === ws.OPEN) ws.send(buf, { binary: true }); }; const attachMsg: AttachMessage = { type: 'attach', acting_user_id: user.id, can_write: canWrite, connection_id: session.connectionId, cols: session.cols, rows: session.rows, }; sendText(attachMsg); // Replay scrollback: ESC c reset, raw bytes, then markers around the bytes. // The ESC c ensures the client terminal starts fresh even if it was already // attached to another session before this one. const scroll = session.scrollbackBytes(); sendBinary(Buffer.from([0x1b, 0x63])); // ESC c — full reset sendText({ type: 'replay_begin', bytes: scroll.length }); if (scroll.length > 0) sendBinary(scroll); sendText({ type: 'replay_end' }); const unsub = session.onOutput((b) => sendBinary(b)); // Register this WS as a viewer so the registry can selectively kick it // (e.g. when its access grant is revoked) without tearing down the whole // SSH session. unsubViewer must fire on `close` along with unsub. const unsubViewer = session.addViewer({ userId: user.id, close: (reason) => { try { sendText({ type: 'close', reason }); } catch { /* socket gone */ } try { // 1008 = Policy Violation — appropriate for authorization revocation. ws.close(1008, reason); } catch { /* already closed */ } }, }); // Heartbeat — without this, a half-dead WS (TCP alive but the peer // can't respond, e.g. laptop suspended or NAT/proxy dropped state) // never fires `close` and the UI just silently swallows user input. // Send ping every 30s; if no pong within the next ping cycle, treat // as dead and terminate so the client switches to `disconnected` // and the user knows to refresh / reconnect. let alive = true; const heartbeatTimer = setInterval(() => { if (!alive) { logger.warn(`[console-ws] heartbeat timeout for task=${session.localTaskId} — terminating`); try { ws.terminate(); } catch { /* already dead */ } return; } alive = false; try { ws.ping(); } catch (e) { logger.warn(`[console-ws] ping failed: ${(e as Error).message}`); } }, 30_000); ws.on('pong', () => { alive = true; }); ws.on('message', (data, isBinary) => { if (isBinary) { if (!canWrite) { sendText({ type: 'notice', severity: 'warn', msg: 'read-only viewer; input ignored.' }); return; } const buf = data as Buffer; const text = buf.toString('utf8'); if (/[\r\n]/.test(text)) { const denyResult = checkConsoleInput( text, patterns.deny.length ? patterns.deny : null, patterns.allow.length ? patterns.allow : null, ); if (!denyResult.ok) { sendText({ type: 'notice', severity: 'error', msg: `command rejected: ${denyResult.reason} (${denyResult.matched ?? 'n/a'})`, }); return; } } session.write(buf, 'human'); return; } try { const msg = JSON.parse(String(data)); if (msg && msg.type === 'resize' && typeof msg.cols === 'number' && typeof msg.rows === 'number') { if (canWrite) session.resize(msg.cols, msg.rows); } } catch (e) { logger.warn(`[console-ws] bad text frame: ${(e as Error).message}`); } }); ws.on('close', () => { clearInterval(heartbeatTimer); unsub(); unsubViewer(); }); } /** * REST router exposing GET /local/tasks/:taskId/console/status. * * Used by the UI to know whether a Console tab should render an attach * button (active=true) or a "no live session" empty state. */ export function createConsoleStatusRouter(deps: { registry: SessionRegistry; requireAuth: any; resolveTask: (taskId: string, user: SimpleUser) => Promise; /** No-auth single-user mode: synthesize a local admin user so the Console * status poll works without login (admin role makes null-owner no-auth * tasks visible via buildVisibilityWhere). Defaults to true. */ authActive?: boolean; }): Router { const r = Router(); r.get( '/local/tasks/:taskId/console/status', deps.requireAuth, async (req: Request, res: Response) => { const taskId = req.params.taskId!; const user = resolveConsoleUser(req, deps.authActive ?? true); if (!user) { res.status(401).json({ error: 'unauthenticated' }); return; } const task = await deps.resolveTask(taskId, user); if (!task) { // Task is missing or not visible to this user. Return 200 // active=false rather than 404: the UI's App.tsx polls this // endpoint every 5 seconds for the currently-selected local // task, and a 404 logs an unsuppressible network error in // the browser DevTools console on every tick (reported as // issue #347 during dogfooding). The poll only needs to // know whether a SSH attach button should be rendered, and // "no, you can't attach" is the same answer whether the // task doesn't exist or just doesn't expose a session — // collapsing both into 200 active=false matches the rest of // the route's fallback shape without leaking task existence // either way. res.json({ active: false }); return; } const s = deps.registry.get(taskId); if (!s) { res.json({ active: false }); return; } res.json({ active: true, connection_id: s.connectionId, started_at: new Date(s.startedAt).toISOString(), last_activity_at: new Date(s.lastActivityAt).toISOString(), cols: s.cols, rows: s.rows, }); }, ); return r; } /** * Map an `OpenConsoleResult.error` code to an HTTP status. The endpoint * surfaces the same structured `error` code string in the JSON body so the * UI can map it to a localized message. Unknown codes default to 400 (caller * error) except the internal failure codes which default to 500. */ function statusForOpenError(error: string | undefined): number { switch (error) { case 'connection_not_found': return 404; case 'no_grant': return 403; case 'host_key_not_verified': case 'host_key_mismatch': case 'connection_change_requires_force': case 'abuse_locked': case 'connection_disabled': return 409; case 'decrypt_failed': case 'open_shell_failed': return 500; // missing_connection_id / missing_task_context / no_user_context / // piece_not_configured / piece_not_allowed / preflight_denied and any // other unexpected code → 400 (the request was malformed or denied at a // layer that maps cleanly onto a bad-request response). default: return 400; } } /** * The preflight helper (`preflight` from engine/tools/ssh.ts) returns a flat * `preflight_denied` for several distinct denials (no grant, abuse lock, * disabled connection, connection not found). The REST contract wants the * specific codes, so the handler re-derives them from the human-readable * message that `openConsoleSession` mirrors into `result.message`. This is a * best-effort refinement layered ON TOP of the real gate — the gate itself * (inside openConsoleSession/preflight) is authoritative and unchanged; we * never widen access here, only narrow a generic 400 into a more specific * 4xx for the UI. */ function refineErrorCode(result: OpenConsoleResult): string { const code = result.error ?? 'unknown'; if (code !== 'preflight_denied') return code; // Match the exact human-readable strings `preflight` (engine/tools/ssh.ts) // produces for each denial reason. Order matters: 'disabled' and 'locked' // are checked before the generic access-denied so a disabled/locked // connection isn't mislabeled as no_grant. const msg = (result.message ?? '').toLowerCase(); if (msg.includes('does not exist')) return 'connection_not_found'; if (msg.includes('is disabled')) return 'connection_disabled'; if (msg.includes('temporarily locked')) return 'abuse_locked'; if (msg.includes('access denied')) return 'no_grant'; return 'preflight_denied'; } /** * REST router exposing POST /local/tasks/:taskId/console/session. * * Lets a user open an SSH console PTY session themselves from a task's * Console tab. The session is keyed by localTaskId, so the WS/xterm and the * AI console tools share it automatically. The handler runs the SAME gate as * the agent-facing SshConsoleEnsure tool: it calls the shared * `openConsoleSession` core, which runs the full preflight (piece membership * / access decision / enabled / abuse / host-key) against the task's piece * name. `allowedConnections: ['*']` is passed because the per-piece * allowed-list is an agent-prompt concept; the authoritative gate is the * access resolver against `task.pieceName`, which still runs inside the core. */ export function createConsoleSessionRouter(deps: { sub: OpenConsoleDeps['sub']; preflight: OpenConsoleDeps['preflight']; requireAuth: any; resolveTask: (taskId: string, user: SimpleUser) => Promise; /** No-auth single-user mode: synthesize a local admin user so users can * open Console sessions without login. Defaults to true. */ authActive?: boolean; }): Router { const r = Router(); r.post( '/local/tasks/:taskId/console/session', // Body parser scoped to THIS route only. The router is mounted on the // whole /api prefix; a router-level (or mount-level) express.json() // would intercept every /api request with the default 100kb limit and // 413 large-but-legitimate bodies (e.g. task attachments) before their // own parsers run. The body here is just connection_id/cols/rows. json({ limit: '4kb' }), deps.requireAuth, async (req: Request, res: Response) => { const taskId = req.params.taskId!; const user = resolveConsoleUser(req, deps.authActive ?? true); if (!user) { res.status(401).json({ error: 'unauthenticated' }); return; } const task = await deps.resolveTask(taskId, user); if (!task) { // Missing OR not visible to this user → opaque 404 (don't leak // task existence across the visibility boundary). res.status(404).json({ error: 'task_not_found' }); return; } const body = (req.body ?? {}) as { connection_id?: unknown; cols?: unknown; rows?: unknown; force_replace?: unknown; }; const connectionId = typeof body.connection_id === 'string' ? body.connection_id : ''; if (!connectionId) { res.status(400).json({ error: 'missing_connection_id' }); return; } const cols = typeof body.cols === 'number' ? body.cols : undefined; const rows = typeof body.rows === 'number' ? body.rows : undefined; const result = await openConsoleSession( { sub: deps.sub, preflight: deps.preflight }, { taskId, connectionId, ownerId: task.ownerId || 'local', userId: user.id, pieceName: task.pieceName, // Per-piece allowed-list is an agent-prompt concept; the real gate // is the access resolver against task.pieceName inside the core. allowedConnections: ['*'], cols, rows, forceReplace: body.force_replace === true, initiator: 'user', }, ); if (result.ok) { res.status(200).json({ ok: true, connection_id: result.connectionId, cols: result.cols, rows: result.rows, ...(result.alreadyActive ? { already_active: true } : {}), }); return; } const code = refineErrorCode(result); res.status(statusForOpenError(code)).json({ error: code }); }, ); return r; }