feat(server): wire /pty-session route + spawn terminal-agent

Server-side glue connecting the Terminal sidebar tab to the new
terminal-agent process.

server.ts:
- New POST /pty-session route. Validates AUTH_TOKEN, mints a gstack_pty
  HttpOnly cookie via pty-session-cookie.ts, posts the cookie value to
  the agent's loopback /internal/grant. Returns the terminalPort + Set-Cookie
  to the extension.
- /health response gains `terminalPort` (just the port number — never a
  shell token). Tokens flow via the cookie path, never /health, because
  /health already surfaces AUTH_TOKEN to localhost callers in headed mode
  (that's a separate v1.1+ TODO).
- /pty-session and /terminal/* are deliberately NOT added to TUNNEL_PATHS,
  so the dual-listener tunnel surface 404s by default-deny.
- Shutdown path now also pkills terminal-agent and unlinks its state files
  (terminal-port + terminal-internal-token) so a reconnect doesn't try to
  hit a dead port.

cli.ts:
- After spawning sidebar-agent.ts, also spawn terminal-agent.ts. Same
  pattern: pkill old instances, Bun.spawn(['bun', 'run', script]) with
  BROWSE_STATE_FILE + BROWSE_SERVER_PORT env. Non-fatal if the spawn
  fails — chat still works without the terminal agent.
This commit is contained in:
Garry Tan
2026-04-25 12:33:56 -07:00
parent c5407b101b
commit 07d4d36edf
2 changed files with 141 additions and 0 deletions
+34
View File
@@ -933,6 +933,40 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
console.error(`[browse] Sidebar agent failed to start: ${err.message}`);
console.error(`[browse] Run manually: bun run ${agentScript}`);
}
// Auto-start terminal agent (non-compiled, parallel to sidebar-agent).
// Owns the PTY WebSocket for the Terminal sidebar tab. Crash-isolated
// from the chat agent per codex outside-voice review.
let termAgentScript = path.resolve(__dirname, 'terminal-agent.ts');
if (!fs.existsSync(termAgentScript)) {
termAgentScript = path.resolve(path.dirname(process.execPath), '..', 'src', 'terminal-agent.ts');
}
try {
if (fs.existsSync(termAgentScript)) {
// Kill old terminal-agents so a stale port file can't trick the
// server into routing /pty-session at a dead listener.
try {
const { spawnSync } = require('child_process');
spawnSync('pkill', ['-f', 'terminal-agent\\.ts'], { stdio: 'ignore', timeout: 3000 });
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err;
}
const termProc = Bun.spawn(['bun', 'run', termAgentScript], {
cwd: config.projectDir,
env: {
...process.env,
BROWSE_STATE_FILE: config.stateFile,
BROWSE_SERVER_PORT: String(newState.port),
},
stdio: ['ignore', 'ignore', 'ignore'],
});
termProc.unref();
console.log(`[browse] Terminal agent started (PID: ${termProc.pid})`);
}
} catch (err: any) {
// Non-fatal: chat still works without the terminal agent.
console.error(`[browse] Terminal agent failed to start: ${err.message}`);
}
} catch (err: any) {
console.error(`[browse] Connect failed: ${err.message}`);
process.exit(1);
+107
View File
@@ -46,6 +46,9 @@ import {
mintSseSessionToken, validateSseSessionToken, extractSseCookie,
buildSseSetCookie, SSE_COOKIE_NAME,
} from './sse-session-cookie';
import {
mintPtySessionToken, buildPtySetCookie, revokePtySessionToken,
} from './pty-session-cookie';
import * as fs from 'fs';
import * as net from 'net';
import * as path from 'path';
@@ -165,6 +168,52 @@ function validateAuth(req: Request): boolean {
return header === `Bearer ${AUTH_TOKEN}`;
}
/**
* Terminal-agent discovery. The non-compiled bun process at
* `browse/src/terminal-agent.ts` writes its chosen port to
* `<stateDir>/terminal-port` and the loopback handshake token to
* `<stateDir>/terminal-internal-token` once it boots. Read on demand —
* lazy so we don't break tests that don't spawn the agent.
*/
function readTerminalPort(): number | null {
try {
const f = path.join(path.dirname(config.stateFile), 'terminal-port');
const v = parseInt(fs.readFileSync(f, 'utf-8').trim(), 10);
return Number.isFinite(v) && v > 0 ? v : null;
} catch { return null; }
}
function readTerminalInternalToken(): string | null {
try {
const f = path.join(path.dirname(config.stateFile), 'terminal-internal-token');
const t = fs.readFileSync(f, 'utf-8').trim();
return t.length > 16 ? t : null;
} catch { return null; }
}
/**
* Push a freshly-minted PTY cookie token to the terminal-agent so its
* /ws upgrade can validate the cookie. Loopback POST authenticated with
* the internal token written by the agent at startup. Fire-and-forget;
* if the agent isn't up yet, the extension just retries /pty-session.
*/
async function grantPtyToken(token: string): Promise<boolean> {
const port = readTerminalPort();
const internal = readTerminalInternalToken();
if (!port || !internal) return false;
try {
const resp = await fetch(`http://127.0.0.1:${port}/internal/grant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${internal}`,
},
body: JSON.stringify({ token }),
signal: AbortSignal.timeout(2000),
});
return resp.ok;
} catch { return false; }
}
/** Extract bearer token from request. Returns the token string or null. */
function extractToken(req: Request): string | null {
const header = req.headers.get('authorization');
@@ -1428,6 +1477,18 @@ async function shutdown(exitCode: number = 0) {
} catch (err: any) {
console.warn('[browse] Failed to kill sidebar-agent:', err.message);
}
// Same for terminal-agent — it owns the PTY listener and would keep
// sitting on its port if we don't kill it.
try {
const { spawnSync } = require('child_process');
spawnSync('pkill', ['-f', 'terminal-agent\\.ts'], { stdio: 'ignore', timeout: 3000 });
} catch (err: any) {
console.warn('[browse] Failed to kill terminal-agent:', err.message);
}
// Best-effort cleanup of agent state files so a reconnect doesn't try to
// hit a dead port.
try { safeUnlinkQuiet(path.join(path.dirname(config.stateFile), 'terminal-port')); } catch {}
try { safeUnlinkQuiet(path.join(path.dirname(config.stateFile), 'terminal-internal-token')); } catch {}
// Clean up CDP inspector sessions
try { detachSession(); } catch (err: any) {
console.warn('[browse] Failed to detach CDP session:', err.message);
@@ -1681,12 +1742,58 @@ async function start() {
// Source of truth is ~/.gstack/security/session-state.json, written
// by sidebar-agent as the classifier warms up.
security: getSecurityStatus(),
// Terminal-agent discovery. ONLY a port number — never a token.
// Tokens flow via the /pty-session HttpOnly cookie path. See
// `pty-session-cookie.ts` for the rationale (codex outside-voice
// finding #2: don't reuse this endpoint for shell auth).
terminalPort: readTerminalPort(),
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// ─── /pty-session — mint Terminal-tab WebSocket cookie ───────────
//
// The extension POSTs here with the bootstrap AUTH_TOKEN, gets back a
// short-lived HttpOnly cookie scoped to the terminal-agent's /ws
// upgrade. We push the cookie value to the agent over loopback so the
// upgrade can validate it. The cookie travels automatically with the
// browser's WebSocket upgrade because it's same-origin to the agent
// when the daemon binds 127.0.0.1. NEVER added to TUNNEL_PATHS — the
// tunnel surface 404s any /pty-session attempt by default-deny.
if (url.pathname === '/pty-session' && req.method === 'POST') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401, headers: { 'Content-Type': 'application/json' },
});
}
const port = readTerminalPort();
if (!port) {
return new Response(JSON.stringify({
error: 'terminal-agent not ready',
}), { status: 503, headers: { 'Content-Type': 'application/json' } });
}
const minted = mintPtySessionToken();
const granted = await grantPtyToken(minted.token);
if (!granted) {
revokePtySessionToken(minted.token);
return new Response(JSON.stringify({
error: 'failed to grant terminal session',
}), { status: 503, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({
terminalPort: port,
expiresAt: minted.expiresAt,
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': buildPtySetCookie(minted.token),
},
});
}
// ─── /connect — setup key exchange for /pair-agent ceremony ────
if (url.pathname === '/connect' && req.method === 'POST') {
if (!checkConnectRateLimit()) {