mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
feat(server): add terminal-agent.ts (PTY for the Terminal sidebar tab)
Translates phoenix gbrowser's Go PTY (cmd/gbd/terminal.go) into a Bun
non-compiled process. Lives separately from `sidebar-agent.ts` so a
WS-framing or PTY-cleanup bug can't take down the chat path (codex
outside-voice review caught the coupling risk).
Architecture:
- Bun.serve on 127.0.0.1:0 (never tunneled).
- POST /internal/grant accepts cookie tokens from the parent server over
loopback, authenticated with a per-boot internal token.
- GET /ws upgrades require BOTH (a) Origin: chrome-extension://<id> and
(b) the gstack_pty cookie minted by /pty-session. Either gate alone is
insufficient (CSWSH defense + auth defense).
- Lazy spawn: claude PTY is not started until the WS receives its first
data frame. Idle sidebar opens cost nothing.
- Bun PTY API: `terminal: { rows, cols, data(t, chunk) }` — verified at
impl time on Bun 1.3.10. proc.terminal.write() for input,
proc.terminal.resize() for resize, proc.kill() + 3s SIGKILL fallback
on close.
- process.on('uncaughtException'|'unhandledRejection') handlers so a
framing bug logs but doesn't kill the listener loop.
Test-only `BROWSE_TERMINAL_BINARY` env override lets the integration
tests spawn /bin/bash instead of requiring claude on every CI runner.
Not yet spawned by anything — wired in the next commit.
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Terminal Agent — PTY-backed Claude Code terminal for the gstack browser
|
||||
* sidebar. Translates the phoenix gbrowser PTY (cmd/gbd/terminal.go) into
|
||||
* Bun, with a few changes informed by codex's outside-voice review:
|
||||
*
|
||||
* - Lives in a separate non-compiled bun process from sidebar-agent.ts so
|
||||
* a bug in WS framing or PTY cleanup can't take down the chat path.
|
||||
* - Binds 127.0.0.1 only — never on the dual-listener tunnel surface.
|
||||
* - Origin validation on the WS upgrade is REQUIRED (not defense-in-depth)
|
||||
* because a localhost shell WS is a real cross-site WebSocket-hijacking
|
||||
* target.
|
||||
* - Cookie-based auth via /internal/grant from the parent server, not a
|
||||
* token in /health.
|
||||
* - Lazy spawn: claude PTY is not spawned until the WS receives its first
|
||||
* data frame. Sidebar opens that never type don't burn a claude session.
|
||||
* - PTY dies with WS close (one PTY per WS). v1.1 may add session
|
||||
* survival; for v1 we match phoenix's lifecycle.
|
||||
*
|
||||
* The PTY uses Bun's `terminal:` spawn option (verified at impl time on
|
||||
* Bun 1.3.10): pass cols/rows + a data callback; write input via
|
||||
* `proc.terminal.write(buf)`; resize via `proc.terminal.resize(cols, rows)`.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { safeUnlink } from './error-handling';
|
||||
|
||||
const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
|
||||
const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port');
|
||||
const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10);
|
||||
const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || ''; // optional: tighten Origin check
|
||||
const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn
|
||||
|
||||
// In-memory cookie token registry. Parent posts /internal/grant after
|
||||
// /pty-session; we validate WS cookies against this set.
|
||||
const validTokens = new Set<string>();
|
||||
|
||||
// Active PTY session per WS. One terminal per connection. Codex finding #4:
|
||||
// uncaught handlers below catch bugs in framing/cleanup so they don't kill
|
||||
// the listener loop.
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[terminal-agent] uncaughtException:', err);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[terminal-agent] unhandledRejection:', reason);
|
||||
});
|
||||
|
||||
interface PtySession {
|
||||
proc: any | null; // Bun.Subprocess once spawned
|
||||
cols: number;
|
||||
rows: number;
|
||||
cookie: string;
|
||||
spawned: boolean;
|
||||
}
|
||||
|
||||
const sessions = new WeakMap<any, PtySession>(); // ws -> session
|
||||
|
||||
/** Find claude on PATH. */
|
||||
function findClaude(): string | null {
|
||||
// Test-only override. Lets the integration tests spawn /bin/bash instead
|
||||
// of requiring claude to be installed on every CI runner. NEVER read in
|
||||
// production (sidebar UI). Documented in browse/test/terminal-agent-integration.test.ts.
|
||||
const override = process.env.BROWSE_TERMINAL_BINARY;
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
// Bun.which is sync and respects PATH. Falls back to a small list of
|
||||
// common install locations if PATH is stripped (e.g., launched from
|
||||
// Conductor with a minimal env).
|
||||
const which = (Bun as any).which?.('claude');
|
||||
if (which) return which;
|
||||
const candidates = [
|
||||
'/opt/homebrew/bin/claude',
|
||||
'/usr/local/bin/claude',
|
||||
`${process.env.HOME}/.local/bin/claude`,
|
||||
`${process.env.HOME}/.bun/bin/claude`,
|
||||
`${process.env.HOME}/.npm-global/bin/claude`,
|
||||
];
|
||||
for (const c of candidates) {
|
||||
try { fs.accessSync(c, fs.constants.X_OK); return c; } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Probe + persist claude availability for the bootstrap card. */
|
||||
function writeClaudeAvailable(): void {
|
||||
const stateDir = path.dirname(STATE_FILE);
|
||||
try { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); } catch {}
|
||||
const found = findClaude();
|
||||
const status = {
|
||||
available: !!found,
|
||||
path: found || undefined,
|
||||
install_url: 'https://docs.anthropic.com/en/docs/claude-code',
|
||||
checked_at: new Date().toISOString(),
|
||||
};
|
||||
const target = path.join(stateDir, 'claude-available.json');
|
||||
const tmp = path.join(stateDir, `.tmp-claude-${process.pid}`);
|
||||
try {
|
||||
fs.writeFileSync(tmp, JSON.stringify(status, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmp, target);
|
||||
} catch {
|
||||
safeUnlink(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Spawn claude in a PTY. Returns null if claude not on PATH. */
|
||||
function spawnClaude(cols: number, rows: number, onData: (chunk: Buffer) => void) {
|
||||
const claudePath = findClaude();
|
||||
if (!claudePath) return null;
|
||||
|
||||
// Match phoenix env so claude knows which browse server to talk to and
|
||||
// doesn't try to autostart its own. BROWSE_HEADED=1 keeps the existing
|
||||
// headed-mode browser; BROWSE_NO_AUTOSTART prevents claude's gstack
|
||||
// tooling from racing to spawn another server.
|
||||
const env: Record<string, string> = {
|
||||
...process.env as any,
|
||||
BROWSE_PORT: String(BROWSE_SERVER_PORT),
|
||||
BROWSE_STATE_FILE: STATE_FILE,
|
||||
BROWSE_NO_AUTOSTART: '1',
|
||||
BROWSE_HEADED: '1',
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
};
|
||||
|
||||
const proc = (Bun as any).spawn([claudePath], {
|
||||
terminal: {
|
||||
rows,
|
||||
cols,
|
||||
data(_terminal: any, chunk: Buffer) { onData(chunk); },
|
||||
},
|
||||
env,
|
||||
});
|
||||
return proc;
|
||||
}
|
||||
|
||||
/** Cleanup a PTY session: SIGINT, then SIGKILL after 3s. */
|
||||
function disposeSession(session: PtySession): void {
|
||||
try { session.proc?.terminal?.close?.(); } catch {}
|
||||
if (session.proc?.pid) {
|
||||
try { session.proc.kill?.('SIGINT'); } catch {}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (session.proc && !session.proc.killed) session.proc.kill?.('SIGKILL');
|
||||
} catch {}
|
||||
}, 3000);
|
||||
}
|
||||
session.proc = null;
|
||||
session.spawned = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the HTTP server. Two routes:
|
||||
* POST /internal/grant — parent server pushes a fresh cookie token
|
||||
* GET /ws — extension upgrades to WebSocket (PTY transport)
|
||||
*
|
||||
* Everything else returns 404. The listener binds 127.0.0.1 only.
|
||||
*/
|
||||
function buildServer() {
|
||||
return Bun.serve({
|
||||
hostname: '127.0.0.1',
|
||||
port: 0,
|
||||
idleTimeout: 0, // PTY connections are long-lived; default idleTimeout would kill them
|
||||
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// /internal/grant — loopback-only handshake from parent server.
|
||||
if (url.pathname === '/internal/grant' && req.method === 'POST') {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth !== `Bearer ${INTERNAL_TOKEN}`) {
|
||||
return new Response('forbidden', { status: 403 });
|
||||
}
|
||||
return req.json().then((body: any) => {
|
||||
if (typeof body?.token === 'string' && body.token.length > 16) {
|
||||
validTokens.add(body.token);
|
||||
}
|
||||
return new Response('ok');
|
||||
}).catch(() => new Response('bad', { status: 400 }));
|
||||
}
|
||||
|
||||
// /internal/revoke — drop a token (called on WS close or bootstrap reload)
|
||||
if (url.pathname === '/internal/revoke' && req.method === 'POST') {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth !== `Bearer ${INTERNAL_TOKEN}`) {
|
||||
return new Response('forbidden', { status: 403 });
|
||||
}
|
||||
return req.json().then((body: any) => {
|
||||
if (typeof body?.token === 'string') validTokens.delete(body.token);
|
||||
return new Response('ok');
|
||||
}).catch(() => new Response('bad', { status: 400 }));
|
||||
}
|
||||
|
||||
// /claude-available — bootstrap card hits this when user clicks "I installed it".
|
||||
if (url.pathname === '/claude-available' && req.method === 'GET') {
|
||||
writeClaudeAvailable();
|
||||
const found = findClaude();
|
||||
return new Response(JSON.stringify({ available: !!found, path: found }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// /ws — WebSocket upgrade. CRITICAL gates:
|
||||
// (1) Origin must be chrome-extension://<id>. Cross-site WS hijacking
|
||||
// defense per codex finding #9.
|
||||
// (2) Cookie gstack_pty must be in validTokens. The cookie was
|
||||
// minted by the parent server's /pty-session route under a
|
||||
// valid AUTH_TOKEN, so a request without it can't get a shell.
|
||||
if (url.pathname === '/ws') {
|
||||
const origin = req.headers.get('origin') || '';
|
||||
const isExtensionOrigin = origin.startsWith('chrome-extension://');
|
||||
if (!isExtensionOrigin) {
|
||||
return new Response('forbidden origin', { status: 403 });
|
||||
}
|
||||
if (EXTENSION_ID && origin !== `chrome-extension://${EXTENSION_ID}`) {
|
||||
return new Response('forbidden origin', { status: 403 });
|
||||
}
|
||||
|
||||
const cookieHeader = req.headers.get('cookie') || '';
|
||||
let cookieToken: string | null = null;
|
||||
for (const part of cookieHeader.split(';')) {
|
||||
const [name, ...rest] = part.trim().split('=');
|
||||
if (name === 'gstack_pty') { cookieToken = rest.join('=') || null; break; }
|
||||
}
|
||||
if (!cookieToken || !validTokens.has(cookieToken)) {
|
||||
return new Response('unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const upgraded = server.upgrade(req, {
|
||||
data: { cookie: cookieToken },
|
||||
});
|
||||
return upgraded ? undefined : new Response('upgrade failed', { status: 500 });
|
||||
}
|
||||
|
||||
return new Response('not found', { status: 404 });
|
||||
},
|
||||
|
||||
websocket: {
|
||||
message(ws, raw) {
|
||||
let session = sessions.get(ws);
|
||||
if (!session) {
|
||||
session = {
|
||||
proc: null,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cookie: (ws.data as any)?.cookie || '',
|
||||
spawned: false,
|
||||
};
|
||||
sessions.set(ws, session);
|
||||
}
|
||||
|
||||
// Text frames are control messages: {type: "resize", cols, rows} or
|
||||
// {type: "tabSwitch", tabId, url, title}. Binary frames are raw input
|
||||
// bytes destined for the PTY stdin.
|
||||
if (typeof raw === 'string') {
|
||||
let msg: any;
|
||||
try { msg = JSON.parse(raw); } catch { return; }
|
||||
if (msg?.type === 'resize') {
|
||||
const cols = Math.max(2, Math.floor(Number(msg.cols) || 80));
|
||||
const rows = Math.max(2, Math.floor(Number(msg.rows) || 24));
|
||||
session.cols = cols;
|
||||
session.rows = rows;
|
||||
try { session.proc?.terminal?.resize?.(cols, rows); } catch {}
|
||||
return;
|
||||
}
|
||||
if (msg?.type === 'tabSwitch') {
|
||||
handleTabSwitch(msg);
|
||||
return;
|
||||
}
|
||||
// Unknown text frame — ignore.
|
||||
return;
|
||||
}
|
||||
|
||||
// Binary input. Lazy-spawn claude on the first byte.
|
||||
if (!session.spawned) {
|
||||
session.spawned = true;
|
||||
const proc = spawnClaude(session.cols, session.rows, (chunk) => {
|
||||
try { ws.sendBinary(chunk); } catch {}
|
||||
});
|
||||
if (!proc) {
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
code: 'CLAUDE_NOT_FOUND',
|
||||
message: 'claude CLI not on PATH. Install: https://docs.anthropic.com/en/docs/claude-code',
|
||||
}));
|
||||
ws.close(4404, 'claude not found');
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
session.proc = proc;
|
||||
// Watch for child exit so the WS closes cleanly when claude exits.
|
||||
proc.exited?.then?.(() => {
|
||||
try { ws.close(1000, 'pty exited'); } catch {}
|
||||
});
|
||||
}
|
||||
try {
|
||||
// raw is a Uint8Array; Bun.Terminal.write accepts string|Buffer.
|
||||
// Convert to Buffer for safety.
|
||||
session.proc?.terminal?.write?.(Buffer.from(raw as Uint8Array));
|
||||
} catch (err) {
|
||||
console.error('[terminal-agent] terminal.write failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
close(ws) {
|
||||
const session = sessions.get(ws);
|
||||
if (session) {
|
||||
disposeSession(session);
|
||||
if (session.cookie) {
|
||||
// Drop the cookie so it can't be replayed against a new PTY.
|
||||
validTokens.delete(session.cookie);
|
||||
}
|
||||
sessions.delete(ws);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab-switch helper: write the active tab to a state file (claude reads it)
|
||||
* and notify the parent server so its activeTabId stays synced. Skips
|
||||
* chrome:// and chrome-extension:// internal pages.
|
||||
*/
|
||||
function handleTabSwitch(msg: { tabId?: number; url?: string; title?: string }): void {
|
||||
const url = msg.url || '';
|
||||
if (!url || url.startsWith('chrome://') || url.startsWith('chrome-extension://')) return;
|
||||
|
||||
const stateDir = path.dirname(STATE_FILE);
|
||||
const ctxFile = path.join(stateDir, 'active-tab.json');
|
||||
const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`);
|
||||
try {
|
||||
fs.writeFileSync(tmp, JSON.stringify({
|
||||
tabId: msg.tabId ?? null,
|
||||
url,
|
||||
title: msg.title ?? '',
|
||||
}), { mode: 0o600 });
|
||||
fs.renameSync(tmp, ctxFile);
|
||||
} catch {
|
||||
safeUnlink(tmp);
|
||||
}
|
||||
|
||||
// Best-effort sync to parent server so its activeTabId tracking matches.
|
||||
// No await; this is fire-and-forget.
|
||||
if (BROWSE_SERVER_PORT > 0) {
|
||||
fetch(`http://127.0.0.1:${BROWSE_SERVER_PORT}/command`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${readBrowseToken()}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
command: 'tab',
|
||||
args: [String(msg.tabId ?? ''), '--no-focus'],
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function readBrowseToken(): string {
|
||||
try {
|
||||
const raw = fs.readFileSync(STATE_FILE, 'utf-8');
|
||||
const j = JSON.parse(raw);
|
||||
return j.token || '';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
// Boot.
|
||||
function main() {
|
||||
writeClaudeAvailable();
|
||||
const server = buildServer();
|
||||
const port = (server as any).port || (server as any).address?.port;
|
||||
if (!port) {
|
||||
console.error('[terminal-agent] failed to bind: no port');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Write port file atomically so the parent server can pick it up.
|
||||
const dir = path.dirname(PORT_FILE);
|
||||
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch {}
|
||||
const tmp = `${PORT_FILE}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(tmp, String(port), { mode: 0o600 });
|
||||
fs.renameSync(tmp, PORT_FILE);
|
||||
|
||||
// Hand the parent the internal token so it can call /internal/grant.
|
||||
// Parent learns INTERNAL_TOKEN via env (TERMINAL_AGENT_INTERNAL_TOKEN below).
|
||||
// We just print it on stdout for the supervising process to pick up if it's
|
||||
// not already in env. Defense against env races at spawn time.
|
||||
console.log(`[terminal-agent] listening on 127.0.0.1:${port} pid=${process.pid}`);
|
||||
|
||||
// Cleanup port file on exit.
|
||||
const cleanup = () => { safeUnlink(PORT_FILE); process.exit(0); };
|
||||
process.on('SIGTERM', cleanup);
|
||||
process.on('SIGINT', cleanup);
|
||||
}
|
||||
|
||||
// Export the internal token so cli.ts can pass the SAME value to the parent
|
||||
// server via env. Parent reads BROWSE_TERMINAL_INTERNAL_TOKEN and uses it
|
||||
// for /internal/grant calls.
|
||||
//
|
||||
// In practice, the agent generates INTERNAL_TOKEN once at boot and writes it
|
||||
// to a state file the parent reads. This avoids env-passing races. See main().
|
||||
const INTERNAL_TOKEN_FILE = path.join(path.dirname(STATE_FILE), 'terminal-internal-token');
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(INTERNAL_TOKEN_FILE), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN, { mode: 0o600 });
|
||||
} catch {}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user