terminal-agent.ts
1213 lines · browse/src/terminal-agent.ts at 3f79d81
1/**
2 * Terminal Agent — PTY-backed Claude Code terminal for the gstack browser
3 * sidebar. Translates the phoenix gbrowser PTY (cmd/gbd/terminal.go) into
4 * Bun, with a few changes informed by codex's outside-voice review:
5 *
6 * - Lives in a separate non-compiled bun process from the browse daemon so
7 * a bug in WS framing or PTY cleanup can't take down the command surface.
8 * - Binds 127.0.0.1 only — never on the dual-listener tunnel surface.
9 * - Origin validation on the WS upgrade is REQUIRED (not defense-in-depth)
10 * because a localhost shell WS is a real cross-site WebSocket-hijacking
11 * target.
12 * - Cookie-based auth via /internal/grant from the parent server, not a
13 * token in /health.
14 * - Lazy spawn: claude PTY is not spawned until the WS receives its first
15 * data frame. Sidebar opens that never type don't burn a claude session.
16 * - PTY dies with WS close (one PTY per WS). v1.1 may add session
17 * survival; for v1 we match phoenix's lifecycle.
18 *
19 * The PTY uses Bun's `terminal:` spawn option (verified at impl time on
20 * Bun 1.3.10): pass cols/rows + a data callback; write input via
21 * `proc.terminal.write(buf)`; resize via `proc.terminal.resize(cols, rows)`.
22 */
23import * as fs from 'fs';
24import * as path from 'path';
25import * as crypto from 'crypto';
26import { writeSecureFile, restrictFilePermissions, mkdirSecure } from './file-permissions';
27import { atomicWriteSync, atomicWriteQuiet } from '../../lib/fs-atomic';
28import { safeUnlink } from './error-handling';
29import { writeAgentRecord, readAgentRecord, clearAgentRecord, readAgentStartTime, acquireAgentStateLock } from './terminal-agent-control';
30import { findAvailablePort } from './port-allocator';
31import { extractPtyCookie } from './pty-session-cookie';
32import { allowedExtensionOrigin } from './extension-id';
33import {
34 createPtyLifecycle, disposePtyProcess, ptyCompletionReason, PTY_SHUTDOWN_GRACE_MS,
35 type PtyCompletion, type PtyLifecycle,
36} from './terminal-pty-lifecycle';
38const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
39const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port');
40const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10);
41const BROWSE_OWNER_PID = parseInt(process.env.BROWSE_OWNER_PID || '0', 10);
42const BROWSE_OWNER_START_TIME = process.env.BROWSE_OWNER_START_TIME || (BROWSE_OWNER_PID > 0 ? readAgentStartTime(BROWSE_OWNER_PID) : '');
43const OWNER_WATCHDOG_MS = parseInt(
44 process.env.GSTACK_TERMINAL_OWNER_WATCHDOG_MS || '15000',
45 10,
46);
47const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn
48/**
49 * Per-boot generation identifier. Loopback /internal/* callers include
50 * `X-Browse-Gen: <CURRENT_GEN>` so a slow agent the watchdog respawned
51 * around can't service a stale grant from the prior generation. Absent
52 * header means "legacy caller" and is accepted (backward compat); a
53 * present-but-mismatched header returns 409 stale generation.
54 */
55const CURRENT_GEN = process.env.BROWSE_AGENT_GEN || crypto.randomBytes(16).toString('base64url');
57// In-memory attach-token registry. Parent posts /internal/grant after
58// /pty-session; we validate WS upgrades against this map.
59//
60// v1.44+: each token is bound to a v1.44 sessionId (the stable, non-secret
61// identifier from browse/src/pty-session-lease.ts). The token grants ONE
62// attach for ONE session — re-attach within the lease window comes through
63// /pty-session/reattach, which mints a fresh token for the same sessionId.
64//
65// Legacy callers can still pass `{token}` without sessionId (the value
66// stays null and the WS upgrade still works); those callers don't get
67// re-attach because there's no stable identifier to match against.
68const validTokens = new Map<string, string | null>(); // token → sessionId
70/**
71 * Reverse index for re-attach lookups: sessionId → live PtySession.
72 * Populated when a WS first attaches with a known sessionId; cleared when
73 * the session is disposed or the lease expires. Used by:
74 * - /ws upgrade: if the incoming attachToken maps to a sessionId that
75 * already has a live session, REPLACE its ws ref instead of spawning.
76 * - /internal/restart: enumerate by sessionId, dispose that one session.
77 *
78 * Kept separate from the WeakMap<ws,PtySession> so re-attach can find the
79 * session by id even after the original ws has gone.
80 */
81const sessionsById = new Map<string, PtySession>();
83// Active PTY session per WS. One terminal per connection. Codex finding #4:
84// uncaught handlers below catch bugs in framing/cleanup so they don't kill
85// the listener loop.
86export interface PtySession {
87 proc: any | null; // Bun.Subprocess once spawned
88 /** Spawn-time identity of the PTY's process group; outlives the leader so shutdown can reap HUP-immune descendants. */
89 group?: PtyGroupIdentity | null;
90 lifecycle?: PtyLifecycle | null;
91 completion?: PtyCompletion;
92 disposed?: boolean;
93 cols: number;
94 rows: number;
95 cookie: string;
96 /**
97 * Current attached websocket. Swapped on re-attach (Commit 3): when a new
98 * WS upgrade matches this session's sessionId, the old liveWs is gone
99 * and the new ws takes its place. The PTY on-data callback closes over
100 * `session`, not the original `ws`, so it always writes to the current
101 * liveWs (or skips the write when detached and liveWs is null).
102 */
103 liveWs: any | null;
104 /**
105 * v1.44+ stable session identifier (from pty-session-lease). Null for
106 * legacy /internal/grant callers that didn't pass one. Used for
107 * targeted /internal/restart and Commit 3 re-attach lookups.
108 */
109 sessionId: string | null;
110 spawned: boolean;
111 /**
112 * 25s server-side WS keepalive interval (v1.44+). Set in the WS `open`
113 * handler, cleared in `close`. We send `{type:"ping",ts}` text frames so
114 * NAT boxes, proxies, and Chrome's MV3 panel-suspend heuristics see the
115 * connection as active; the client either replies with `{type:"pong"}`
116 * or fires its own 25s `{type:"keepalive"}` cycle. Either path keeps
117 * the underlying TCP from being silently dropped.
118 */
119 pingInterval: ReturnType<typeof setInterval> | null;
120 /**
121 * Commit 3 scrollback ring buffer. Each PTY write appends a frame; the
122 * total byte count is capped at RING_BUFFER_MAX_BYTES with oldest frames
123 * evicted first. On re-attach, the surviving frames are replayed as a
124 * single binary frame (prefixed with the v1.44 reset sequence) so the
125 * user sees their last screen of output. Frame boundaries preserve UTF-8
126 * + ANSI-CSI boundaries because each frame is the exact buffer that
127 * spawnClaude's on-data callback emitted.
128 */
129 ringBuffer: Buffer[];
130 ringBufferBytes: number;
131 /**
132 * Tracks whether the PTY is currently in xterm alt-screen mode. claude's
133 * TUI enters alt-screen (CSI ?1049h) during tool calls and exits (CSI
134 * ?1049l) when returning to the main prompt. On re-attach, the replay
135 * prelude must re-enter alt-screen if the original PTY left it active,
136 * otherwise the replay renders against the main screen and the cursor
137 * + colors end up in the wrong place.
138 */
139 altScreenActive: boolean;
140 /**
141 * Detach state machine (Commit 3). When the WS closes for a reason OTHER
142 * than the v1.44 intentional-restart code (4001), we keep the PtySession
143 * alive for the detach window (default 60s) so a re-attach within the
144 * window can resume the same PTY and replay the ring buffer. The timer
145 * disposes the session if no re-attach arrives in time.
146 */
147 detached: boolean;
148 detachTimer: ReturnType<typeof setTimeout> | null;
149}
151/**
152 * WS keepalive interval. 25s is comfortably under the lowest common NAT
153 * idle timeout (typically 30-60s) and shorter than Chromium's WebSocket
154 * dead-peer threshold. Test-overridable via env so the v1.44 e2e tests
155 * can compress idle-window assertions to <1s without waiting half a
156 * minute per assertion.
157 */
158const KEEPALIVE_INTERVAL_MS = parseInt(
159 process.env.GSTACK_PTY_KEEPALIVE_INTERVAL_MS || '25000',
160 10,
161);
163/**
164 * Commit 3 scrollback ring buffer cap. 1 MB is enough for a full screen
165 * of dense claude output (including a recent tool result), small enough
166 * that a worst-case 10 detached sessions only cost ~10 MB of RSS.
167 * Env-overridable so e2e tests can verify eviction without writing 1 MB
168 * of fixture data per assertion.
169 */
170const RING_BUFFER_MAX_BYTES = parseInt(
171 process.env.GSTACK_PTY_RING_BUFFER_BYTES || `${1024 * 1024}`,
172 10,
173);
175/**
176 * Commit 3 detach window — how long to keep a session alive after WS
177 * close (with any code other than 4001 intentional-restart) so a
178 * re-attach can resume the same PTY. 60s is long enough to cover a
179 * Chrome MV3 service-worker suspend cycle, a wifi blip, or a brief
180 * laptop sleep; short enough that genuinely-closed sessions don't
181 * stack up unbounded.
182 */
183const DETACH_WINDOW_MS = parseInt(
184 process.env.GSTACK_PTY_DETACH_WINDOW_MS || '60000',
185 10,
186);
188/**
189 * Append a frame to a session's ring buffer, evicting oldest frames if
190 * the total byte count exceeds RING_BUFFER_MAX_BYTES. Eviction is at
191 * frame boundaries (one PTY write = one frame), so we never cut a
192 * multi-byte UTF-8 sequence or a partial ANSI CSI in half — claude's
193 * on-data callback emits coherent frames.
194 *
195 * Side effect: scans the appended chunk for alt-screen enter/exit
196 * sequences (CSI ?1049h / CSI ?1049l) and updates session.altScreenActive
197 * so the re-attach prelude knows whether to re-enter alt-screen.
198 */
199export function appendToRingBuffer(session: PtySession, frame: Buffer): void {
200 session.ringBuffer.push(frame);
201 session.ringBufferBytes += frame.length;
202 while (session.ringBufferBytes > RING_BUFFER_MAX_BYTES && session.ringBuffer.length > 1) {
203 const evicted = session.ringBuffer.shift()!;
204 session.ringBufferBytes -= evicted.length;
205 }
206 // Alt-screen tracking. Scan for the canonical xterm enter/exit pairs.
207 // We do this on every append (not just on attach) so the state is
208 // correct even if many frames have flowed since the last attach.
209 const ascii = frame.toString('latin1'); // single-byte view is enough — the codes are 7-bit ASCII
210 // Use lastIndexOf so trailing state wins when both appear in one frame
211 // (e.g., a quick tool-call open+close inside one render pass).
212 const enterIdx = ascii.lastIndexOf('\x1b[?1049h');
213 const exitIdx = ascii.lastIndexOf('\x1b[?1049l');
214 if (enterIdx >= 0 && enterIdx > exitIdx) session.altScreenActive = true;
215 else if (exitIdx >= 0 && exitIdx > enterIdx) session.altScreenActive = false;
216}
218/**
219 * Build the re-attach replay payload: server-side reset prelude + the
220 * accumulated ring buffer. The client side writes RIS (`\x1bc`) to xterm
221 * BEFORE feeding this payload in, so the layout is:
222 *
223 * 1. Client: `\x1bc` (RIS — full reset, clears pre-blip xterm content)
224 * 2. Server: `\x1b[!p` (DECSTR soft reset — re-defaults char attributes)
225 * 3. Server: optional `\x1b[?1049h` if we were in alt-screen at detach
226 * 4. Server: ring buffer contents, in append order
227 *
228 * The client coordinates the order by waiting for a `{type:"reattach-begin"}`
229 * text frame before treating the next binary frame as replay. That separation
230 * is what lets us prepend reset codes without clobbering the live stream
231 * that resumes immediately after.
232 */
233export function buildReplayPayload(session: PtySession): Buffer {
234 const parts: Buffer[] = [];
235 parts.push(Buffer.from('\x1b[!p'));
236 if (session.altScreenActive) parts.push(Buffer.from('\x1b[?1049h'));
237 for (const frame of session.ringBuffer) parts.push(frame);
238 return Buffer.concat(parts);
239}
241const sessions = new WeakMap<any, PtySession>(); // ws -> session
242// Strong ownership index for shutdown. sessions is intentionally weak and
243// sessionsById excludes legacy grants, so neither can enumerate every PTY
244// child when the terminal-agent is replaced or its owner disappears.
245const activeSessions = new Set<PtySession>();
246// Set once shutdown starts: no new connection or PTY spawn during the grace.
247let shuttingDown = false;
248// Groups of disposed sessions whose delayed reap has not run yet; shutdown reaps them too.
249const pendingGroupReaps = new Set<PtyGroupIdentity>();
251/**
252 * A PTY child is its own session and process-group leader (pgid = sid =
253 * pid). That identity, with the leader's start time, stays checkable after
254 * the leader exits, so descendants that ignore SIGHUP can still be found.
255 */
256export interface PtyGroupIdentity { pgid: number; sid: number; startTime: number }
257interface ProcStat { state: string; pgrp: number; session: number; startTime: number }
259/** Linux /proc/<pid>/stat fields after the parenthesised comm; null when absent (or not Linux). */
260export function readProcStat(pid: number, procRoot = '/proc'): ProcStat | null {
261 let raw: string;
262 try { raw = fs.readFileSync(path.join(procRoot, String(pid), 'stat'), 'utf8'); } catch { return null; }
263 const f = raw.slice(raw.lastIndexOf(')') + 2).split(' ');
264 const stat = { state: f[0] ?? '', pgrp: Number(f[2]), session: Number(f[3]), startTime: Number(f[19]) };
265 return Number.isSafeInteger(stat.pgrp) && Number.isSafeInteger(stat.session) && Number.isSafeInteger(stat.startTime) ? stat : null;
266}
268/** The group identity of a freshly spawned PTY child, or null unless it provably leads its own session. */
269export function ptyGroupIdentity(pid: number, procRoot = '/proc'): PtyGroupIdentity | null {
270 const stat = readProcStat(pid, procRoot);
271 if (!stat || stat.pgrp !== pid || stat.session !== pid) return null;
272 return { pgid: pid, sid: pid, startTime: stat.startTime };
273}
275/**
276 * Live processes proven to belong to the group: same pgid and sid, started
277 * no earlier than its leader. A live process holding the leader's PID with
278 * another start time means the number was reused, so nothing is ours.
279 */
280export function ownedGroupMembers(id: PtyGroupIdentity, procRoot = '/proc'): number[] {
281 const leader = readProcStat(id.pgid, procRoot);
282 if (leader && leader.startTime !== id.startTime) return [];
283 let entries: string[];
284 try { entries = fs.readdirSync(procRoot); } catch { return []; }
285 const members: number[] = [];
286 for (const name of entries) {
287 if (!/^\d+$/.test(name)) continue;
288 const stat = readProcStat(Number(name), procRoot);
289 if (stat && stat.state !== 'Z' && stat.pgrp === id.pgid && stat.session === id.sid && stat.startTime >= id.startTime) members.push(Number(name));
290 }
291 return members;
292}
294/** SIGKILL every proven member of the group; returns how many were signalled. */
295export function reapOwnedGroup(id: PtyGroupIdentity, procRoot = '/proc', kill: (pid: number, signal: string) => void = process.kill): number {
296 const members = ownedGroupMembers(id, procRoot);
297 for (const pid of members) { try { kill(pid, 'SIGKILL'); } catch {} }
298 return members.length;
299}
301/** Find claude on PATH. */
302function findClaude(): string | null {
303 // Test-only override. Lets the integration tests spawn /bin/bash instead
304 // of requiring claude to be installed on every CI runner. NEVER read in
305 // production (sidebar UI). Documented in browse/test/terminal-agent-integration.test.ts.
306 const override = process.env.BROWSE_TERMINAL_BINARY;
307 if (override && fs.existsSync(override)) return override;
308 // Bun.which is sync and respects PATH. Falls back to a small list of
309 // common install locations if PATH is stripped (e.g., launched from
310 // Conductor with a minimal env).
311 const which = (Bun as any).which?.('claude');
312 if (which) return which;
313 const candidates = [
314 '/opt/homebrew/bin/claude',
315 '/usr/local/bin/claude',
316 `${process.env.HOME}/.local/bin/claude`,
317 `${process.env.HOME}/.bun/bin/claude`,
318 `${process.env.HOME}/.npm-global/bin/claude`,
319 ];
320 for (const c of candidates) {
321 try { fs.accessSync(c, fs.constants.X_OK); return c; } catch {}
322 }
323 return null;
324}
326/** Probe + persist claude availability for the bootstrap card. */
327function writeClaudeAvailable(): void {
328 const stateDir = path.dirname(STATE_FILE);
329 try { mkdirSecure(stateDir); } catch {}
330 const found = findClaude();
331 const status = {
332 available: !!found,
333 path: found || undefined,
334 install_url: 'https://docs.anthropic.com/en/docs/claude-code',
335 checked_at: new Date().toISOString(),
336 };
337 const target = path.join(stateDir, 'claude-available.json');
338 // Fire-and-forget state file: a failed write must not break boot.
339 if (atomicWriteQuiet(target, JSON.stringify(status, null, 2), { mode: 0o600 })) {
340 restrictFilePermissions(target); // Windows ACL hardening
341 }
342}
344/**
345 * System-prompt hint passed to claude via --append-system-prompt. Tells
346 * claude what tab-awareness affordances exist in this session so it
347 * doesn't have to discover them by trial. The user can override anything
348 * here just by saying so — system prompt is a soft hint, not a contract.
349 *
350 * Two paths claude has:
351 * 1. Read live state from <stateDir>/tabs.json + active-tab.json
352 * (updated continuously by the gstack browser extension).
353 * 2. Run $B tab, $B tabs, $B tab-each <command> to act on tabs. The
354 * tab-each helper fans a single command across every open tab and
355 * returns per-tab results as JSON.
356 */
357function buildTabAwarenessHint(stateDir: string): string {
358 const tabsFile = path.join(stateDir, 'tabs.json');
359 const activeFile = path.join(stateDir, 'active-tab.json');
360 return [
361 'You are running inside the gstack browser sidebar with live access to the user\'s browser tabs.',
362 '',
363 'Tab state files (kept fresh automatically by the extension):',
364 ` ${tabsFile} — all open tabs: tabs[] of {tabId, url, title, active, pinned, windowId}`,
365 ` ${activeFile} — the currently active tab: {tabId, url, title}`,
366 'Read these any time the user asks about "tabs", "the current page", or anything multi-tab. They\'re kept current, so read them instead of running $B tabs when you only need to know what\'s open.',
367 '',
368 'Tab manipulation commands (via $B):',
369 ' ($B is the gstack browse CLI. This shell does not set it, so resolve it the way the /browse skill does: the project\'s .claude/skills/gstack/browse/dist/browse if present, else ~/.claude/skills/gstack/browse/dist/browse.)',
370 ' $B tab <id> — switch to a tab',
371 ' $B newtab [url] — open a new tab',
372 ' $B closetab [id] — close a tab (current if no id)',
373 ' $B tab-each <command> — fan out a command across every tab; returns JSON results',
374 '',
375 'When the user asks for multi-tab work, prefer $B tab-each. Examples:',
376 ' $B tab-each snapshot -i — grab a snapshot from every tab',
377 ' $B tab-each text — pull clean text from every tab',
378 ' $B tab-each title — list every tab\'s title',
379 '',
380 'You\'re in a real terminal with a real PTY — slash commands, /resume, ANSI colors all work as in a normal claude session.',
381 ].join('\n');
382}
384/** Spawn claude in a PTY. Returns null if claude not on PATH. */
385function spawnClaude(cols: number, rows: number, lifecycle: PtyLifecycle) {
386 const claudePath = findClaude();
387 if (!claudePath) return null;
389 // Match phoenix env so claude knows which browse server to talk to and
390 // doesn't try to autostart its own. BROWSE_HEADED=1 keeps the existing
391 // headed-mode browser; BROWSE_NO_AUTOSTART prevents claude's gstack
392 // tooling from racing to spawn another server.
393 const env: Record<string, string> = {
394 ...process.env as any,
395 BROWSE_PORT: String(BROWSE_SERVER_PORT),
396 BROWSE_STATE_FILE: STATE_FILE,
397 BROWSE_NO_AUTOSTART: '1',
398 BROWSE_HEADED: '1',
399 TERM: 'xterm-256color',
400 COLORTERM: 'truecolor',
401 };
403 // --append-system-prompt is the right injection surface (per `claude --help`):
404 // it gets appended to the model's system prompt, so claude treats this as
405 // contextual guidance, not a user message. Don't use a leading PTY write
406 // for this — that would show up as if the user typed the hint, polluting
407 // the visible transcript.
408 const stateDir = path.dirname(STATE_FILE);
409 const tabHint = buildTabAwarenessHint(stateDir);
411 const proc = (Bun as any).spawn([claudePath, '--append-system-prompt', tabHint], {
412 windowsHide: true,
413 terminal: {
414 rows,
415 cols,
416 data(_terminal: any, chunk: Buffer) { lifecycle.data(chunk); },
417 exit(_terminal: any, code: number, signal: string | null) {
418 lifecycle.readerEnded(code, signal);
419 },
420 },
421 env,
422 });
423 return proc;
424}
426/** Cleanup a PTY session: SIGINT, then SIGKILL after 3s. */
427function disposeSession(session: PtySession): void {
428 if (session.disposed) return;
429 activeSessions.delete(session);
430 // Suppress callbacks caused by explicit close, and cancel any drain deadline.
431 session.disposed = true;
432 session.lifecycle?.dispose();
433 session.lifecycle = null;
434 const proc = session.proc;
435 session.proc = null;
436 session.spawned = false;
437 disposePtyProcess(proc);
438 // Unlike the direct-child fallback, the group reap is not cancelled when
439 // the leader exits: a descendant that ignores SIGHUP outlives it.
440 const group = session.group;
441 if (!group) return;
442 pendingGroupReaps.add(group);
443 setTimeout(() => { pendingGroupReaps.delete(group); reapOwnedGroup(group); }, PTY_SHUTDOWN_GRACE_MS).unref?.();
444}
446function sendPtyCompletion(session: PtySession): void {
447 if (!session.completion || !session.liveWs) return;
448 // Keep process status and reader status distinct. Linux PTY shutdown can
449 // report reader status 1; Bun exposes no errno to distinguish it from other
450 // I/O errors. Keep completeness unknown for that status, rather than claiming
451 // clean EOF or loss; only a missing reader callback proves drain timeout.
452 try { session.liveWs.send(JSON.stringify({ type: 'pty-exit', ...session.completion })); } catch {}
453 try { session.liveWs.close(1000, ptyCompletionReason(session.completion)); } catch {}
454}
456/**
457 * Build the HTTP server. Two routes:
458 * POST /internal/grant — parent server pushes a fresh cookie token
459 * GET /ws — extension upgrades to WebSocket (PTY transport)
460 *
461 * Everything else returns 404. The listener binds 127.0.0.1 only.
462 */
463/**
464 * Validate a loopback /internal/* request. Returns null when the request
465 * is allowed; otherwise returns the Response to send back. Centralizes
466 * bearer auth + the v1.44 X-Browse-Gen generation check so adding a new
467 * /internal/* route is a one-liner.
468 */
469function checkInternalAuth(req: Request): Response | null {
470 const auth = req.headers.get('authorization');
471 if (auth !== `Bearer ${INTERNAL_TOKEN}`) {
472 return new Response('forbidden', { status: 403 });
473 }
474 const headerGen = req.headers.get('x-browse-gen');
475 if (headerGen && headerGen !== CURRENT_GEN) {
476 return new Response('stale generation', { status: 409 });
477 }
478 return null;
479}
481/**
482 * Wrap a JSON-bodied /internal/* handler with the standard bearer-auth +
483 * generation-check + json-parse + error-response boilerplate. The handler
484 * `fn` is called with the parsed body; whatever it returns is JSON-stringified
485 * into a 200 Response, or the handler can return a Response directly to
486 * customize status / headers. Throwing from `fn` collapses to a 400 "bad".
487 *
488 * Centralizing the dance kills the copy-paste pattern of bearer + gen check
489 * + req.json().then(...).catch(...) that every /internal/* route needs.
490 * New routes become a single call to internalHandler.
491 */
492async function internalHandler<T>(
493 req: Request,
494 fn: (body: any) => T | Promise<T> | Response | Promise<Response>,
495): Promise<Response> {
496 const denied = checkInternalAuth(req);
497 if (denied) return denied;
498 let body: any;
499 try {
500 body = await req.json();
501 } catch {
502 return new Response('bad', { status: 400 });
503 }
504 try {
505 const result = await fn(body);
506 if (result instanceof Response) return result;
507 if (result === undefined || result === null) return new Response('ok');
508 return new Response(JSON.stringify(result), {
509 status: 200,
510 headers: { 'Content-Type': 'application/json' },
511 });
512 } catch {
513 return new Response('bad', { status: 400 });
514 }
515}
517/**
518 * Spawn the claude PTY for a session if it hasn't been spawned yet.
519 * Used by both the legacy binary-frame spawn trigger and the v1.44 explicit
520 * `{type:"start"}` text-frame trigger. Idempotent on `session.spawned`.
521 *
522 * Returns true if claude is now running, false if spawn failed (e.g. claude
523 * binary not on PATH). On failure, the caller is expected to have already
524 * surfaced the error to the client (or will via the next frame).
525 */
526function maybeSpawnPty(ws: any, session: PtySession): boolean {
527 if (shuttingDown || session.disposed || session.completion) return false;
528 if (session.spawned) return true;
529 session.spawned = true;
530 let leftover = Buffer.alloc(0);
531 const forward = (flush: Buffer) => {
532 if (!flush.length) return;
533 appendToRingBuffer(session, flush);
534 if (session.liveWs) {
535 try { session.liveWs.sendBinary(flush); } catch {}
536 }
537 };
538 const lifecycle = createPtyLifecycle({
539 onData(chunk) {
540 const combined = Buffer.concat([leftover, Buffer.from(chunk)]);
541 // UTF-8 boundary detection (issue #1272). Look back at most 3 bytes
542 // for the start of an incomplete multibyte sequence and defer it.
543 let safeEnd = combined.length;
544 for (let i = combined.length - 1; i >= Math.max(0, combined.length - 3); i--) {
545 const b = combined[i];
546 if ((b & 0x80) === 0) { safeEnd = i + 1; break; }
547 if ((b & 0xC0) === 0x80) continue;
548 const expected = (b & 0xE0) === 0xC0 ? 2 : (b & 0xF0) === 0xE0 ? 3 : 4;
549 safeEnd = (combined.length - i >= expected) ? combined.length : i;
550 break;
551 }
552 const flush = combined.slice(0, safeEnd);
553 leftover = combined.slice(safeEnd);
554 forward(flush);
555 },
556 onComplete(completion) {
557 // Preserve a final incomplete UTF-8 sequence as bytes as well. A reader
558 // error/deadline remains explicit in the completion record.
559 forward(leftover);
560 leftover = Buffer.alloc(0);
561 session.completion = completion;
562 disposeSession(session);
563 sendPtyCompletion(session);
564 },
565 });
566 session.lifecycle = lifecycle;
567 const proc = spawnClaude(session.cols, session.rows, lifecycle);
568 if (!proc) {
569 lifecycle.dispose();
570 try {
571 ws.send(JSON.stringify({
572 type: 'error',
573 code: 'CLAUDE_NOT_FOUND',
574 message: 'claude CLI not on PATH. Install: https://docs.anthropic.com/en/docs/claude-code',
575 }));
576 ws.close(4404, 'claude not found');
577 } catch {}
578 return false;
579 }
580 session.proc = proc;
581 session.group = ptyGroupIdentity(proc.pid);
582 proc.exited.then(
583 (code: number) => lifecycle.exited(code, proc.signalCode ?? null),
584 () => lifecycle.exited(null, proc.signalCode ?? null, true),
585 );
586 return true;
587}
589interface TerminalAgentWsData {
590 cookie: string;
591 sessionId: string | null;
592}
594function buildServer(port: number) {
595 return Bun.serve<TerminalAgentWsData>({
596 hostname: '127.0.0.1',
597 // #2314: allocated from the SAME fixed 10000-60000 scan range the main
598 // server uses (port-allocator.ts, decision 8) — never `port: 0`. Binding
599 // 0 drew from the OS EPHEMERAL range (49152-65535 on macOS), where this
600 // weeks-lived agent squatted ports that short-lived `app.listen(0)` test
601 // servers expected to receive, absorbing their traffic as phantom 404s.
602 port,
603 idleTimeout: 0, // PTY connections are long-lived; default idleTimeout would kill them
605 fetch(req, server) {
606 if (shuttingDown) return new Response('shutting down', { status: 503 });
607 const url = new URL(req.url);
609 // /internal/grant — loopback-only handshake from parent server.
610 // v1.44+: accepts `{token, sessionId?}`. The sessionId binding lets
611 // the agent route re-attach attempts (same sessionId, fresh token)
612 // back to the same PtySession. Legacy callers passing just `{token}`
613 // still work — sessionId becomes null and re-attach is unavailable
614 // for that grant.
615 if (url.pathname === '/internal/grant' && req.method === 'POST') {
616 return internalHandler(req, (body) => {
617 if (typeof body?.token === 'string' && body.token.length > 16) {
618 const sid = typeof body?.sessionId === 'string' && body.sessionId.length > 0
619 ? body.sessionId
620 : null;
621 validTokens.set(body.token, sid);
622 }
623 });
624 }
626 // /internal/revoke — drop a token (called on WS close or bootstrap reload)
627 if (url.pathname === '/internal/revoke' && req.method === 'POST') {
628 return internalHandler(req, (body) => {
629 if (typeof body?.token === 'string') validTokens.delete(body.token);
630 });
631 }
633 // /internal/restart — dispose the PtySession for a specific sessionId.
634 // Scoped to one caller (not enumerate-all). Server.ts /pty-restart
635 // posts here with the caller's sessionId; we kill ONLY that PTY,
636 // leaving any other live sidebar tabs untouched. Codex T2 of the
637 // eng review caught this gap — pre-spec the route would have
638 // disposed all sessions.
639 if (url.pathname === '/internal/restart' && req.method === 'POST') {
640 return internalHandler(req, (body) => {
641 const sid = typeof body?.sessionId === 'string' ? body.sessionId : null;
642 if (!sid) return { killed: 0 };
643 const session = sessionsById.get(sid);
644 if (!session) return { killed: 0 };
645 // Cancel any pending detach timer before disposal — otherwise it
646 // would fire later against an already-disposed session.
647 if (session.detachTimer) {
648 clearTimeout(session.detachTimer);
649 session.detachTimer = null;
650 }
651 disposeSession(session);
652 sessionsById.delete(sid);
653 // Disposal no longer closes the socket through proc.exited. Retire
654 // its heartbeat and grant explicitly; late input cannot respawn this
655 // disposed session while the close handshake is in progress.
656 if (session.pingInterval) {
657 clearInterval(session.pingInterval);
658 session.pingInterval = null;
659 }
660 if (session.cookie) validTokens.delete(session.cookie);
661 try { session.liveWs?.close(4001, 'pty restarted'); } catch {}
662 return { killed: 1 };
663 });
664 }
666 // /internal/healthz — liveness probe used by the v1.44 watchdog.
667 // Returns this agent's pid + gen + active session count without
668 // touching claude binary lookup (which can fail for non-process
669 // reasons and isn't a useful liveness signal). GET — no body to parse,
670 // so it stays on the bare checkInternalAuth gate.
671 if (url.pathname === '/internal/healthz' && req.method === 'GET') {
672 const denied = checkInternalAuth(req);
673 if (denied) return denied;
674 return new Response(JSON.stringify({
675 pid: process.pid,
676 gen: CURRENT_GEN,
677 sessions: validTokens.size,
678 completedSessions: [...sessionsById.values()].filter(session => session.completion).length,
679 }), { status: 200, headers: { 'Content-Type': 'application/json' } });
680 }
682 // /claude-available — bootstrap card hits this when user clicks "I installed it".
683 if (url.pathname === '/claude-available' && req.method === 'GET') {
684 writeClaudeAvailable();
685 const found = findClaude();
686 return new Response(JSON.stringify({ available: !!found, path: found }), {
687 status: 200,
688 headers: { 'Content-Type': 'application/json' },
689 });
690 }
692 // /ws — WebSocket upgrade. CRITICAL gates:
693 // (1) Origin must be exactly the pinned (or gstack-config configured)
694 // extension. Any other origin, other extensions included, is 403.
695 // (2) Token must be in validTokens. We accept the token via two
696 // transports for compatibility:
697 // - Sec-WebSocket-Protocol (preferred for browsers — the only
698 // auth header settable from the browser WebSocket API)
699 // - Cookie gstack_pty (works for non-browser callers and
700 // same-port browser callers; doesn't survive the cross-port
701 // jump from server.ts:34567 to the agent's random port
702 // when SameSite=Strict is set)
703 // Either path works; both verify against the same in-memory
704 // validTokens Set, populated by the parent server's
705 // authenticated /pty-session → /internal/grant chain.
706 if (url.pathname === '/ws') {
707 if (req.headers.get('origin') !== allowedExtensionOrigin()) {
708 return new Response('forbidden origin', { status: 403 });
709 }
711 // Try Sec-WebSocket-Protocol first. Format: a single token, possibly
712 // with a `gstack-pty.` prefix (which we strip). Browsers send a
713 // comma-separated list when multiple were requested; we pick the
714 // first that matches a known token.
715 const protoHeader = req.headers.get('sec-websocket-protocol') || '';
716 let token: string | null = null;
717 for (const raw of protoHeader.split(',').map(s => s.trim()).filter(Boolean)) {
718 const candidate = raw.startsWith('gstack-pty.') ? raw.slice('gstack-pty.'.length) : raw;
719 if (validTokens.has(candidate)) {
720 token = candidate;
721 break;
722 }
723 }
725 // Fallback: Cookie gstack_pty (legacy / non-browser callers).
726 // Parsing is shared with the server via extractPtyCookie; VALIDATION
727 // deliberately stays against the agent's own validTokens map — the
728 // server's registry lives in a different process.
729 if (!token) {
730 const candidate = extractPtyCookie(req);
731 if (candidate && validTokens.has(candidate)) {
732 token = candidate;
733 }
734 }
736 if (!token) {
737 return new Response('unauthorized', { status: 401 });
738 }
740 // v1.44+: surface the token's sessionId binding to the upgraded ws.
741 // open() reads it via ws.data and registers the session in
742 // sessionsById so /internal/restart and (Commit 3) re-attach
743 // lookups can find it.
744 const sessionId = validTokens.get(token) ?? null;
745 // No explicit Sec-WebSocket-Protocol echo: Bun >= 1.3 auto-echoes the
746 // first offered protocol in the 101 response, so setting the header
747 // here produced a DUPLICATE header — strict clients (Chromium, python
748 // websockets) reject the handshake per RFC 6455 and the sidebar
749 // terminal could never connect. Verified on Bun 1.3.6.
750 const upgraded = server.upgrade(req, {
751 data: { cookie: token, sessionId },
752 });
753 return upgraded ? undefined : new Response('upgrade failed', { status: 500 });
754 }
756 return new Response('not found', { status: 404 });
757 },
759 websocket: {
760 /**
761 * Spawn the claude PTY for `session` if it hasn't been spawned yet.
762 * Called from both message paths: the legacy binary-frame trigger
763 * (any keystroke) AND the v1.44 explicit `{type:"start"}` trigger
764 * (forceRestart sends this on every fresh WS to get an eager prompt
765 * without requiring the user to type). Idempotent — a second call
766 * after `spawned: true` is a no-op.
767 */
768 open(ws) {
769 const sessionId = ws.data?.sessionId ?? null;
770 const cookie = ws.data?.cookie || '';
772 // Commit 3 re-attach: if this sessionId already has a detached
773 // PtySession in sessionsById, REPLACE its liveWs ref and replay
774 // the ring buffer. The PTY process is unchanged — claude keeps
775 // running through the wifi blip / panel-suspend cycle.
776 if (sessionId) {
777 const existing = sessionsById.get(sessionId);
778 if (existing) {
779 if (existing.detachTimer) {
780 clearTimeout(existing.detachTimer);
781 existing.detachTimer = null;
782 }
783 existing.detached = false;
784 existing.liveWs = ws;
785 existing.cookie = cookie;
786 // Re-bind the WS-keyed map so resize/close/message handlers
787 // can still find this session via the new ws.
788 sessions.set(ws, existing);
789 // Restart keepalive on the new ws.
790 if (existing.pingInterval) clearInterval(existing.pingInterval);
791 existing.pingInterval = setInterval(() => {
792 try { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } catch {}
793 }, KEEPALIVE_INTERVAL_MS);
794 // Tell the client to prep its xterm (write RIS) before the
795 // replay binary arrives. Order matters — the binary frame
796 // immediately after this text frame IS the replay.
797 try { ws.send(JSON.stringify({ type: 'reattach-begin', sessionId })); } catch {}
798 try { ws.sendBinary(buildReplayPayload(existing)); } catch {}
799 // A child can finish while detached. Replay its final bytes before
800 // reporting completion; never spawn a replacement into that lease.
801 sendPtyCompletion(existing);
802 return;
803 }
804 }
806 const session: PtySession = {
807 proc: null,
808 cols: 80,
809 rows: 24,
810 cookie,
811 liveWs: ws,
812 sessionId,
813 spawned: false,
814 pingInterval: null,
815 ringBuffer: [],
816 ringBufferBytes: 0,
817 altScreenActive: false,
818 detached: false,
819 detachTimer: null,
820 };
821 activeSessions.add(session);
822 session.pingInterval = setInterval(() => {
823 try {
824 ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
825 } catch {
826 // ws likely closed mid-tick; close handler clears the interval.
827 }
828 }, KEEPALIVE_INTERVAL_MS);
829 sessions.set(ws, session);
830 // Index by sessionId for /internal/restart + Commit 3 re-attach.
831 if (sessionId) sessionsById.set(sessionId, session);
832 },
834 message(ws, raw) {
835 let session = sessions.get(ws);
836 if (session && (session.disposed || session.liveWs !== ws)) return;
837 if (!session) {
838 // Fallback for any path where open() didn't fire (shouldn't happen
839 // in Bun.serve but keeps the spawn path safe). No keepalive on
840 // this branch — open() is the supported entry point.
841 session = {
842 proc: null,
843 cols: 80,
844 rows: 24,
845 cookie: ws.data?.cookie || '',
846 liveWs: ws,
847 sessionId: ws.data?.sessionId ?? null,
848 spawned: false,
849 pingInterval: null,
850 ringBuffer: [],
851 ringBufferBytes: 0,
852 altScreenActive: false,
853 detached: false,
854 detachTimer: null,
855 };
856 activeSessions.add(session);
857 sessions.set(ws, session);
858 if (session.sessionId) sessionsById.set(session.sessionId, session);
859 }
861 // Text frames are control messages: {type: "resize", cols, rows},
862 // {type: "tabSwitch", tabId, url, title}, {type: "tabState", ...},
863 // or v1.44 keepalive frames: {type: "pong", ts}, {type: "keepalive"}.
864 // Binary frames are raw input bytes destined for the PTY stdin.
865 if (typeof raw === 'string') {
866 let msg: any;
867 try { msg = JSON.parse(raw); } catch { return; }
868 if (msg?.type === 'resize') {
869 const cols = Math.max(2, Math.floor(Number(msg.cols) || 80));
870 const rows = Math.max(2, Math.floor(Number(msg.rows) || 24));
871 session.cols = cols;
872 session.rows = rows;
873 try { session.proc?.terminal?.resize?.(cols, rows); } catch {}
874 return;
875 }
876 if (msg?.type === 'tabSwitch') {
877 handleTabSwitch(msg);
878 return;
879 }
880 if (msg?.type === 'tabState') {
881 handleTabState(msg);
882 return;
883 }
884 if (msg?.type === 'pong' || msg?.type === 'keepalive' || msg?.type === 'ping') {
885 // Keepalive frames — accepted and silently dropped. The mere
886 // fact that the WS carried this frame is the liveness signal;
887 // there's no application-level state to update at this layer.
888 // `ping` is acknowledged here too in case the client (or a
889 // future agent peer) mirrors our server-side ping shape.
890 return;
891 }
892 if (msg?.type === 'start') {
893 // v1.44 explicit spawn trigger. forceRestart sends this
894 // immediately on every fresh WS so claude boots without the
895 // user having to type a keystroke (pre-v1.44, the lazy-binary
896 // spawn made restart look stuck until the user typed). No-op
897 // if already spawned.
898 maybeSpawnPty(ws, session);
899 return;
900 }
901 // Unknown text frame — ignore.
902 return;
903 }
905 // Binary input. Lazy-spawn claude on the first byte if `start`
906 // wasn't sent first. Both paths land in the same maybeSpawnPty
907 // helper for behavior parity.
908 if (!session.spawned) {
909 if (!maybeSpawnPty(ws, session)) return;
910 }
911 try {
912 // raw is a Uint8Array; Bun.Terminal.write accepts string|Buffer.
913 // Convert to Buffer for safety.
914 session.proc?.terminal?.write?.(Buffer.from(raw as Uint8Array));
915 } catch (err) {
916 console.error('[terminal-agent] terminal.write failed:', err);
917 }
918 },
920 close(ws, code, _reason) {
921 const session = sessions.get(ws);
922 if (!session) return;
923 // Always drop the WS-keyed map entry and the per-attach
924 // attachToken — the attach grant was single-use.
925 sessions.delete(ws);
926 const cookie = ws.data?.cookie;
927 if (cookie) validTokens.delete(cookie);
928 // A reattach can replace liveWs before the old socket's close arrives.
929 // That stale callback must not retire the new socket, grant or child.
930 if (session.liveWs !== ws) return;
931 // Keepalive lives with the WS — every attach starts a fresh one.
932 if (session.pingInterval) {
933 clearInterval(session.pingInterval);
934 session.pingInterval = null;
935 }
937 // Commit 3 detach state machine. If the close was intentional
938 // (code 4001 = restart, 4404 = no-claude error), dispose
939 // immediately — there's no value in keeping the PTY alive.
940 // Otherwise enter the detach window: claude keeps running, the
941 // ring buffer keeps accumulating, and a re-attach with the same
942 // sessionId within DETACH_WINDOW_MS picks back up. If the timer
943 // fires without a re-attach, the session is disposed normally.
944 //
945 // Sessions without a sessionId (legacy single-shot grants) can't
946 // re-attach by definition — fall through to immediate dispose.
947 const intentional = code === 4001 || code === 4404 || code === 1000;
948 if (intentional || !session.sessionId) {
949 disposeSession(session);
950 if (session.sessionId && sessionsById.get(session.sessionId) === session) {
951 sessionsById.delete(session.sessionId);
952 }
953 return;
954 }
956 // Mark detached and start the disposal timer. The session stays
957 // in sessionsById so the next /ws upgrade with the same
958 // sessionId can find and reattach to it.
959 session.detached = true;
960 session.liveWs = null;
961 session.detachTimer = setTimeout(() => {
962 if (!session.detached) return; // re-attached in the meantime
963 disposeSession(session);
964 if (session.sessionId && sessionsById.get(session.sessionId) === session) {
965 sessionsById.delete(session.sessionId);
966 }
967 }, DETACH_WINDOW_MS);
968 // setTimeout returns a Bun Timer; unref so the detach window
969 // doesn't keep the process alive past natural shutdown.
970 (session.detachTimer as any)?.unref?.();
971 },
972 },
973 });
974}
976/**
977 * Tab-switch helper: write the active tab to a state file (claude reads it)
978 * and notify the parent server so its activeTabId stays synced. Skips
979 * chrome:// and chrome-extension:// internal pages.
980 */
981/**
982 * Live tab snapshot. Writes <stateDir>/tabs.json (full list) and updates
983 * <stateDir>/active-tab.json (current active). claude can read these any
984 * time without invoking $B tabs — saves a round-trip when the model just
985 * needs to check the landscape before deciding what to do.
986 */
987function handleTabState(msg: {
988 active?: { tabId?: number; url?: string; title?: string } | null;
989 tabs?: Array<{ tabId?: number; url?: string; title?: string; active?: boolean; windowId?: number; pinned?: boolean; audible?: boolean }>;
990 reason?: string;
991}): void {
992 const stateDir = path.dirname(STATE_FILE);
993 try { mkdirSecure(stateDir); } catch {}
995 // tabs.json — full list
996 if (Array.isArray(msg.tabs)) {
997 const payload = {
998 updatedAt: new Date().toISOString(),
999 reason: msg.reason || 'unknown',
1000 tabs: msg.tabs.map(t => ({
1001 tabId: t.tabId ?? null,
1002 url: t.url || '',
1003 title: t.title || '',
1004 active: !!t.active,
1005 windowId: t.windowId ?? null,
1006 pinned: !!t.pinned,
1007 audible: !!t.audible,
1008 })),
1009 };
1010 const target = path.join(stateDir, 'tabs.json');
1011 // Fire-and-forget state file: atomic write (via lib/fs-atomic) so
1012 // claude never reads a half-written JSON document; failures swallowed.
1013 if (atomicWriteQuiet(target, JSON.stringify(payload, null, 2), { mode: 0o600 })) {
1014 restrictFilePermissions(target); // Windows ACL hardening
1015 }
1016 }
1018 // active-tab.json — single active tab. Skip chrome-internal pages so
1019 // claude doesn't see chrome:// or chrome-extension:// URLs as
1020 // "current target."
1021 const active = msg.active;
1022 if (active && active.url && !active.url.startsWith('chrome://') && !active.url.startsWith('chrome-extension://')) {
1023 const ctxFile = path.join(stateDir, 'active-tab.json');
1024 const ok = atomicWriteQuiet(ctxFile, JSON.stringify({
1025 tabId: active.tabId ?? null,
1026 url: active.url,
1027 title: active.title ?? '',
1028 }), { mode: 0o600 });
1029 if (ok) restrictFilePermissions(ctxFile); // Windows ACL hardening
1030 }
1031}
1033function handleTabSwitch(msg: { tabId?: number; url?: string; title?: string }): void {
1034 const url = msg.url || '';
1035 if (!url || url.startsWith('chrome://') || url.startsWith('chrome-extension://')) return;
1037 const stateDir = path.dirname(STATE_FILE);
1038 const ctxFile = path.join(stateDir, 'active-tab.json');
1039 // Fire-and-forget: atomic write via lib/fs-atomic, failures swallowed.
1040 const ok = atomicWriteQuiet(ctxFile, JSON.stringify({
1041 tabId: msg.tabId ?? null,
1042 url,
1043 title: msg.title ?? '',
1044 }), { mode: 0o600 });
1045 if (ok) restrictFilePermissions(ctxFile); // Windows ACL hardening
1047 // Best-effort sync to parent server so its activeTabId tracking matches.
1048 // No await; this is fire-and-forget.
1049 if (BROWSE_SERVER_PORT > 0) {
1050 fetch(`http://127.0.0.1:${BROWSE_SERVER_PORT}/command`, {
1051 method: 'POST',
1052 headers: {
1053 'Content-Type': 'application/json',
1054 'Authorization': `Bearer ${readBrowseToken()}`,
1055 },
1056 body: JSON.stringify({
1057 command: 'tab',
1058 args: [String(msg.tabId ?? ''), '--no-focus'],
1059 }),
1060 }).catch(() => {});
1061 }
1062}
1064function readBrowseToken(): string {
1065 try {
1066 const raw = fs.readFileSync(STATE_FILE, 'utf-8');
1067 const j = JSON.parse(raw);
1068 return j.token || '';
1069 } catch { return ''; }
1070}
1072// Boot.
1073async function main() {
1074 const dir = path.dirname(PORT_FILE);
1075 if (process.env.BROWSE_AGENT_GEN) {
1076 const deadline = Date.now() + 2000;
1077 while (Date.now() < deadline) {
1078 const pending = readAgentRecord(dir);
1079 if (pending?.gen === CURRENT_GEN && pending.pid === process.pid) break;
1080 if (pending && pending.gen !== CURRENT_GEN) throw new Error('terminal-agent startup record was replaced');
1081 await Bun.sleep(25);
1082 }
1083 const recorded = readAgentRecord(dir);
1084 if (recorded?.pid !== process.pid || recorded.ownerPid !== BROWSE_OWNER_PID || recorded.ownerStartTime !== BROWSE_OWNER_START_TIME) {
1085 throw new Error('terminal-agent startup record was not confirmed');
1086 }
1087 }
1088 const pauseFile = process.env.NODE_ENV === 'test' ? process.env.GSTACK_TERMINAL_TEST_PUBLISH_BARRIER : undefined;
1089 if (pauseFile) {
1090 fs.writeFileSync(`${pauseFile}.ready`, 'ready');
1091 while (!fs.existsSync(pauseFile)) await Bun.sleep(10);
1092 }
1093 writeClaudeAvailable();
1094 // #2314: allocate from the shared fixed scan range, then bind. Probe-then-
1095 // bind has a TOCTOU window — a concurrent process can take the port between
1096 // the probe and Bun.serve, which throws and would kill the boot with no
1097 // retry (main().catch → exit 1). Re-allocate and retry a few times; each
1098 // iteration probes fresh, so only a genuine race lands here.
1099 let server: ReturnType<typeof buildServer> | undefined;
1100 let lastBindErr: unknown;
1101 for (let attempt = 0; attempt < 5 && !server; attempt++) {
1102 const allocatedPort = await findAvailablePort();
1103 try {
1104 server = buildServer(allocatedPort);
1105 } catch (err) {
1106 lastBindErr = err;
1107 }
1108 }
1109 if (!server) {
1110 console.error(`[terminal-agent] failed to bind after 5 attempts: ${lastBindErr}`);
1111 process.exit(1);
1112 }
1113 const port = (server as any).port || (server as any).address?.port;
1114 if (!port) {
1115 console.error('[terminal-agent] failed to bind: no port');
1116 process.exit(1);
1117 }
1119 // Write port file atomically so the parent server can pick it up.
1120 // Throws on failure — a boot without a discoverable port file is broken.
1121 const releasePublication = acquireAgentStateLock(dir, 5000, process.env.BROWSE_AGENT_GEN);
1122 let record;
1123 try {
1124 const current = readAgentRecord(dir);
1125 if (current && current.pid !== process.pid && current.pid > 0) throw new Error('terminal-agent record was replaced before bind');
1126 record = process.env.BROWSE_AGENT_GEN ? current : {
1127 pid: process.pid, gen: CURRENT_GEN, startedAt: Date.now(), startTime: readAgentStartTime(process.pid),
1128 ownerPid: BROWSE_OWNER_PID, ownerStartTime: BROWSE_OWNER_START_TIME,
1129 };
1130 if (!record || record.pid !== process.pid || record.gen !== CURRENT_GEN) throw new Error('terminal-agent record was replaced before bind');
1131 if (!process.env.BROWSE_AGENT_GEN) writeAgentRecord(dir, record);
1132 writeSecureFile(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN);
1133 atomicWriteSync(PORT_FILE, String(port), { mode: 0o600 });
1134 restrictFilePermissions(PORT_FILE);
1135 } finally { releasePublication(); }
1137 // Hand the parent the internal token so it can call /internal/grant.
1138 // Parent learns INTERNAL_TOKEN via env (TERMINAL_AGENT_INTERNAL_TOKEN below).
1139 // We just print it on stdout for the supervising process to pick up if it's
1140 // not already in env. Defense against env races at spawn time.
1141 console.log(`[terminal-agent] listening on 127.0.0.1:${port} pid=${process.pid} gen=${CURRENT_GEN}`);
1143 // Cleanup port file + agent record on exit.
1144 let cleaningUp = false;
1145 const cleanup = async () => {
1146 if (cleaningUp) return;
1147 cleaningUp = true;
1148 shuttingDown = true;
1149 // disposePtyProcess sends SIGINT and schedules the direct child's SIGKILL
1150 // fallback. Each PTY's process group, captured at spawn, is reaped after
1151 // the same grace even when its leader exits first, so descendants that
1152 // ignore SIGHUP do not outlive the agent. Awaiting here keeps both alive
1153 // instead of process.exit(0) abandoning them.
1154 const owned = [...activeSessions];
1155 const childExits = owned.map(session => session.proc?.exited).filter((exited): exited is Promise<number> => !!exited);
1156 for (const session of owned) disposeSession(session);
1157 const groups = [...pendingGroupReaps];
1158 sessionsById.clear();
1159 try {
1160 const releaseCleanup = acquireAgentStateLock(dir, 25);
1161 try {
1162 if (readAgentRecord(dir)?.gen === CURRENT_GEN) {
1163 safeUnlink(PORT_FILE);
1164 safeUnlink(INTERNAL_TOKEN_FILE);
1165 clearAgentRecord(dir, record);
1166 }
1167 } finally { releaseCleanup(); }
1168 } catch {}
1169 let childrenExited = childExits.length === 0;
1170 void Promise.allSettled(childExits).then(() => { childrenExited = true; });
1171 const deadline = Date.now() + PTY_SHUTDOWN_GRACE_MS;
1172 while (Date.now() < deadline && !(childrenExited && groups.every(group => ownedGroupMembers(group).length === 0))) await Bun.sleep(100);
1173 for (const group of groups) reapOwnedGroup(group);
1174 if (!childrenExited) await Promise.race([Promise.allSettled(childExits), Bun.sleep(250)]);
1175 process.exit(0);
1176 };
1177 process.on('SIGTERM', () => { void cleanup(); });
1178 process.on('SIGINT', () => { void cleanup(); });
1180 // The terminal agent is intentionally detached so it survives the short-lived
1181 // CLI launcher, but its real owner is the persistent browse server. If that
1182 // server crashes or is killed before running normal shutdown, the agent would
1183 // otherwise be adopted by PID 1 and live forever. Poll the server PID and use
1184 // the same cleanup path as an intentional shutdown when it disappears.
1185 if (BROWSE_OWNER_PID > 0) {
1186 const ownerWatchdog = setInterval(() => {
1187 if (!BROWSE_OWNER_START_TIME || readAgentStartTime(BROWSE_OWNER_PID) !== BROWSE_OWNER_START_TIME
1188 || readAgentRecord(dir)?.gen !== CURRENT_GEN) void cleanup();
1189 }, OWNER_WATCHDOG_MS);
1190 (ownerWatchdog as any)?.unref?.();
1191 }
1192}
1194// Export the internal token so cli.ts can pass the SAME value to the parent
1195// server via env. Parent reads BROWSE_TERMINAL_INTERNAL_TOKEN and uses it
1196// for /internal/grant calls.
1197//
1198// In practice, the agent generates INTERNAL_TOKEN once at boot and writes it
1199// to a state file the parent reads. This avoids env-passing races. See main().
1200const INTERNAL_TOKEN_FILE = path.join(path.dirname(STATE_FILE), 'terminal-internal-token');
1202if (import.meta.main) {
1203 process.on('uncaughtException', (err) => {
1204 console.error('[terminal-agent] uncaughtException:', err);
1205 });
1206 process.on('unhandledRejection', (reason) => {
1207 console.error('[terminal-agent] unhandledRejection:', reason);
1208 });
1209 main().catch((err) => {
1210 console.error(`[terminal-agent] boot failed: ${err instanceof Error ? err.message : String(err)}`);
1211 process.exit(1);
1212 });
1213}