pty.ts
254 lines · browse/src/routes/pty.ts at 3f79d81
1/**
2 * Terminal (PTY) routes: session mint, re-attach, restart, dispose, lease
3 * refresh from terminal-agent, and the pre-inject prompt-injection scan.
4 * All are local-only: the tunnel surface 404s them by default-deny.
5 */
7import { json, jsonError, type RouteEntry } from './table';
8import { mintPtySessionToken, buildPtySetCookie, revokePtySessionToken } from '../pty-session-cookie';
9import { mintLease, validateLease, refreshLease, revokeLease } from '../pty-session-lease';
10import { isSidecarAvailable, scanWithSidecar } from '../security-sidecar-client';
11import { sanitizeReplacer } from '../sanitize';
13async function readJsonOrNull(req: Request): Promise<any> {
14 try { return await req.json(); } catch { return null; }
15}
17export const ptyRoutes: RouteEntry[] = [
18 // ─── /pty-session — mint sessionId + lease + attachToken ─────────
19 //
20 // v1.44+ four-tuple shape:
21 // { terminalPort, sessionId, attachToken, leaseExpiresAt }
22 //
23 // - sessionId : stable, non-secret. Safe to log. Identifies "this
24 // terminal" across re-attaches.
25 // - attachToken : short-lived (30 min wall, single attach in practice
26 // since the agent revokes on WS close). Bearer for
27 // the /ws upgrade.
28 // - leaseExpiresAt: client-visible deadline for the lease. Re-attach
29 // only works inside this window.
30 //
31 // The lease + attachToken are minted together so a successful
32 // /pty-session is one round trip. Re-attach mints a fresh attachToken
33 // for the SAME sessionId via /pty-session/reattach.
34 {
35 method: 'POST', path: '/pty-session', auth: 'root-bearer', surfaces: ['local'],
36 handler: async (_req, _r, ctx) => {
37 const port = ctx.terminal.readPort();
38 if (!port) return jsonError(503, 'terminal-agent not ready');
39 const lease = mintLease();
40 const minted = mintPtySessionToken();
41 const granted = await ctx.terminal.grantToken(minted.token, lease.sessionId);
42 if (!granted) {
43 revokePtySessionToken(minted.token);
44 revokeLease(lease.sessionId);
45 return jsonError(503, 'failed to grant terminal session');
46 }
47 return json({
48 terminalPort: port,
49 sessionId: lease.sessionId,
50 attachToken: minted.token,
51 leaseExpiresAt: lease.expiresAt,
52 // Legacy alias — extensions still on the v1.43 wire shape keep
53 // working. Drop after one minor release once dogfood confirms.
54 ptySessionToken: minted.token,
55 expiresAt: minted.expiresAt,
56 }, { headers: { 'Set-Cookie': buildPtySetCookie(minted.token) } });
57 },
58 },
60 // ─── /pty-session/reattach — mint fresh attachToken for existing sessionId
61 //
62 // Validates the lease (rejects unknown/expired sessionId with 410 Gone),
63 // mints a fresh short-lived attachToken bound to the same sessionId, and
64 // pushes it to the agent. The client opens a new WS with the new token;
65 // the agent matches the sessionId binding and re-attaches to the existing
66 // PtySession (kept alive for the 60s detach window).
67 {
68 method: 'POST', path: '/pty-session/reattach', auth: 'root-bearer', surfaces: ['local'],
69 handler: async (req, _r, ctx) => {
70 const port = ctx.terminal.readPort();
71 if (!port) return jsonError(503, 'terminal-agent not ready');
72 const body = await readJsonOrNull(req);
73 const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
74 const v = sessionId ? validateLease(sessionId) : { ok: false as const };
75 // 410 Gone — session window has closed (lease expired or never
76 // existed). Client must fall back to /pty-session for a brand-new
77 // session.
78 if (!v.ok) return jsonError(410, 'lease expired or unknown');
79 const minted = mintPtySessionToken();
80 const granted = await ctx.terminal.grantToken(minted.token, sessionId!);
81 if (!granted) {
82 revokePtySessionToken(minted.token);
83 return jsonError(503, 'failed to grant attach token');
84 }
85 return json({
86 terminalPort: port,
87 sessionId,
88 attachToken: minted.token,
89 leaseExpiresAt: v.ok ? v.expiresAt : 0,
90 });
91 },
92 },
94 // ─── /pty-restart — one-transaction kill + fresh mint ────────────
95 //
96 // The Restart button. Synchronously disposes the caller's existing
97 // PtySession on the agent, revokes the old lease, mints a fresh
98 // sessionId + lease + attachToken, and returns the new 4-tuple in
99 // one response. Zero race window between kill and mint.
100 {
101 method: 'POST', path: '/pty-restart', auth: 'root-bearer', surfaces: ['local'],
102 handler: async (req, _r, ctx) => {
103 const port = ctx.terminal.readPort();
104 if (!port) return jsonError(503, 'terminal-agent not ready');
105 const body = await readJsonOrNull(req);
106 const oldSessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
107 // Best-effort dispose. Missing/unknown sessionId is non-fatal —
108 // the client may be doing a "restart from scratch" with no prior
109 // session (e.g. ENDED state). The fresh mint always proceeds.
110 if (oldSessionId) {
111 await ctx.terminal.restartSession(oldSessionId);
112 revokeLease(oldSessionId);
113 }
114 const lease = mintLease();
115 const minted = mintPtySessionToken();
116 const granted = await ctx.terminal.grantToken(minted.token, lease.sessionId);
117 if (!granted) {
118 revokePtySessionToken(minted.token);
119 revokeLease(lease.sessionId);
120 return jsonError(503, 'failed to grant terminal session');
121 }
122 return json({
123 terminalPort: port,
124 sessionId: lease.sessionId,
125 attachToken: minted.token,
126 leaseExpiresAt: lease.expiresAt,
127 });
128 },
129 },
131 // ─── /pty-dispose — explicit teardown (pagehide / browser quit) ──
132 //
133 // sendBeacon-compatible: accepts the auth token in the BODY so the
134 // extension's pagehide handler can fire it without setting headers
135 // (sendBeacon doesn't support custom headers). Without this, every
136 // browser quit + sidebar close leaves a zombie PTY alive for the 60s
137 // detach window.
138 {
139 method: 'POST', path: '/pty-dispose', auth: 'handler', surfaces: ['local'],
140 handlerAuth: 'root token as Authorization: Bearer or as the JSON body authToken (sendBeacon); 401 Unauthorized otherwise',
141 handler: async (req, _r, ctx) => {
142 const body = await readJsonOrNull(req);
143 const authTokenFromBody = typeof body?.authToken === 'string' ? body.authToken : null;
144 const header = req.headers.get('authorization');
145 const headerToken = header?.startsWith('Bearer ') ? header.slice(7) : null;
146 if (!ctx.isRootTokenValue(headerToken) && !ctx.isRootTokenValue(authTokenFromBody)) {
147 return jsonError(401, 'Unauthorized');
148 }
149 const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
150 if (sessionId) {
151 await ctx.terminal.restartSession(sessionId);
152 revokeLease(sessionId);
153 }
154 return json({ ok: true });
155 },
156 },
158 // ─── /internal/lease-refresh — loopback from terminal-agent on keepalive
159 //
160 // PTY-only idle reset: the headless daemon's idle timer must reset only
161 // on active PTY usage, not on every passive SSE consumer. Terminal-agent
162 // calls this endpoint (lazily, only when its cached lease is within 5 min
163 // of expiry) on its 25s keepalive cycle. Refreshing the lease here also
164 // bumps lastActivity so the daemon stays alive while a sidebar terminal
165 // is actively in use. Bound to the root authToken so an external caller
166 // can't refresh another user's lease. Body: {sessionId}.
167 {
168 method: 'POST', path: '/internal/lease-refresh', auth: 'root-bearer', surfaces: ['local'],
169 handler: async (req, _r, ctx) => {
170 const body = await readJsonOrNull(req);
171 const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
172 const r = sessionId ? refreshLease(sessionId) : { ok: false as const };
173 if (!r.ok) return jsonError(410, 'lease expired or unknown');
174 ctx.resetIdleTimer();
175 return json({ ok: true, expiresAt: r.expiresAt });
176 },
177 },
179 // ─── /pty-inject-scan — pre-inject prompt-injection scan for the
180 // extension's gstackInjectToTerminal callers. The extension routes
181 // every page-derived text through this endpoint BEFORE writing to
182 // the PTY (#1370). Sidecar absence degrades to L4 unavailable
183 // (extension shows WARN + user confirm per D7).
184 {
185 method: 'POST', path: '/pty-inject-scan', auth: 'root-bearer', surfaces: ['local'],
186 handler: async (req) => {
187 const reply = (body: unknown, status: number) => json(body, { status, replacer: sanitizeReplacer });
188 // 64KB request cap. Defense against accidentally posting an
189 // entire page DOM into the PTY path.
190 const contentLength = Number(req.headers.get('content-length') || '0');
191 if (contentLength > 64 * 1024) return reply({ error: 'payload-too-large', limit: 65536 }, 413);
192 let body: { text?: unknown; origin?: unknown } = {};
193 try {
194 body = (await req.json()) as { text?: unknown; origin?: unknown };
195 } catch {
196 return reply({ error: 'malformed-json' }, 400);
197 }
198 const text = typeof body.text === 'string' ? body.text : '';
199 if (text.length === 0) return reply({ error: 'missing-text' }, 400);
201 // L1-L3 honest accounting:
202 // - URL blocklist forced to BLOCK in PTY context (override
203 // BROWSE_CONTENT_FILTER default — page-derived text in the
204 // REPL is a higher-risk surface than ordinary tool output).
205 // - L4 ML classifier via the sidecar when available.
206 // - L1-L3 envelope/datamarking is INFORMATIONAL only; the
207 // verdict is driven by the URL blocklist + L4.
208 // See CLAUDE.md "Sidebar security stack".
209 let verdict: 'PASS' | 'WARN' | 'BLOCK' = 'PASS';
210 const reasons: string[] = [];
212 // Quick URL-blocklist check: text containing a known bad-actor
213 // domain → BLOCK.
214 if (/(\bbit\.ly|\btinyurl\.com|\bdiscord\.gg)/i.test(text)) {
215 verdict = 'BLOCK';
216 reasons.push('url-blocklist');
217 }
219 const sidecarAvail = isSidecarAvailable();
220 let l4: { available: boolean; verdict?: unknown; error?: string } = {
221 available: sidecarAvail.available,
222 };
223 if (sidecarAvail.available && verdict !== 'BLOCK') {
224 try {
225 const { verdict: layerVerdict } = await scanWithSidecar(text, { timeoutMs: 5000 });
226 l4 = { available: true, verdict: layerVerdict };
227 // LayerSignal shape: { verdict: 'safe'|'suspicious'|'unsafe', ... }
228 const lv = (layerVerdict as { verdict?: string })?.verdict;
229 if (lv === 'unsafe') {
230 verdict = 'BLOCK';
231 reasons.push('l4-unsafe');
232 } else if (lv === 'suspicious') {
233 verdict = 'WARN';
234 reasons.push('l4-suspicious');
235 }
236 } catch (err) {
237 l4 = { available: false, error: err instanceof Error ? err.message : String(err) };
238 // L4 failure during scan: degrade to WARN per D7.
239 if (verdict === 'PASS') {
240 verdict = 'WARN';
241 reasons.push('l4-unavailable');
242 }
243 }
244 } else if (!sidecarAvail.available && verdict === 'PASS') {
245 verdict = 'WARN';
246 reasons.push(`l4-unavailable:${sidecarAvail.reason ?? 'unknown'}`);
247 }
249 // BLOCK decisions are surfaced in the response shape; the extension
250 // logs the BLOCK event into its own activity feed on receipt.
251 return reply({ verdict, reasons, l4, datamark: '<untrusted-page-content>' }, 200);
252 },
253 },
254];