mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-14 08:18:40 +08:00
* build: vendor xterm@5 for the Terminal sidebar tab
Adds xterm@5 + xterm-addon-fit as devDependencies and a `vendor:xterm`
build step that copies the assets into `extension/lib/` at build time.
The vendored files are .gitignored so the npm version stays the source
of truth. xterm@5 is eval-free, so no MV3 CSP changes needed.
No runtime callers yet — this just stages the assets.
* feat(server): add pty-session-cookie module for the Terminal tab
Mirrors `sse-session-cookie.ts` exactly. Mints short-lived 30-min HttpOnly
cookies for authenticating the Terminal-tab WebSocket upgrade against
the terminal-agent. Same TTL, same opportunistic-pruning shape, same
"scoped tokens never valid as root" invariant. Two registries instead of
one because the cookie names are different (`gstack_sse` vs `gstack_pty`)
and the token spaces must not overlap.
No callers yet — wired up in the next commit.
* 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.
* 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.
* feat(extension): Terminal as default sidebar tab
Adds a primary tab bar (Terminal | Chat) above the existing tab-content
panes. Terminal is the default-active tab; clicking Chat returns to the
existing claude -p one-shot flow which is preserved verbatim.
manifest.json: adds ws://127.0.0.1:*/ to host_permissions so MV3 doesn't
block the WebSocket upgrade.
sidepanel.html: new primary-tabs nav, new #tab-terminal pane with a
"Press any key to start Claude Code" bootstrap card, claude-not-found
install card, xterm mount point, and "session ended" restart UI. Loads
xterm.js + xterm-addon-fit + sidepanel-terminal.js. tab-chat is no
longer the .active default.
sidepanel.js: new activePrimaryPaneId() helper that reads which primary
tab is selected. Debug-close paths now route back to whichever primary
pane is active (was hardcoded to tab-chat). Primary-tab click handler
toggles .active classes and aria-selected. window.gstackServerPort and
window.gstackAuthToken exposed so sidepanel-terminal.js can build the
/pty-session POST and the WS URL.
sidepanel-terminal.js (new): xterm.js lifecycle. Lazy-spawn — first
keystroke fires POST /pty-session, then opens
ws://127.0.0.1:<terminalPort>/ws. Origin + cookie are set automatically
by the browser. Resize observer sends {type:"resize"} text frames.
ResizeObserver, tab-switch hooks, restart button, install-card retry.
On WS close shows "Session ended, click to restart" — no auto-reconnect
(codex outside-voice flagged that as session-burning).
sidepanel.css: primary-tabs bar + Terminal pane styling (full-height
xterm container, install card, ended state).
* test: terminal-agent + cookie module + sidebar default-tab regression
Three new test files:
terminal-agent.test.ts (16 tests): pty-session-cookie mint/validate/
revoke, Set-Cookie shape (HttpOnly + SameSite=Strict + Path=/, NO Secure
since 127.0.0.1 over HTTP), source-level guards that /pty-session and
/terminal/* are NOT in TUNNEL_PATHS, /health does NOT surface ptyToken
or gstack_pty, terminal-agent binds 127.0.0.1, /ws upgrade enforces
chrome-extension:// Origin AND gstack_pty cookie, lazy-spawn invariant
(spawnClaude is called from message handler, not upgrade), uncaughtException/
unhandledRejection handlers exist, SIGINT-then-SIGKILL cleanup.
terminal-agent-integration.test.ts (7 tests): spawns the agent as a real
subprocess in a tmp state dir. Verifies /internal/grant accepts/rejects
the loopback token, /ws gates (no Origin → 403, bad Origin → 403, no
cookie → 401), real WebSocket round-trip with /bin/bash via the
BROWSE_TERMINAL_BINARY override (write 'echo hello-pty-world\n', read it
back), and resize message acceptance.
sidebar-tabs.test.ts (13 tests): structural regression suite locking the
load-bearing invariants of the default-tab change — Terminal is .active,
Chat is not, xterm assets are loaded, debug-close path no longer hardcodes
tab-chat (uses activePrimaryPaneId), primary-tab click handler exists,
chat surface is not accidentally deleted, terminal JS does NOT auto-
reconnect on close, manifest declares ws:// + http:// localhost host
permissions, no unsafe-eval.
Plan called for Playwright + extension regression; the codebase doesn't
ship Playwright extension launcher infra, so we follow the existing
extension-test pattern (source-level structural assertions). Same
load-bearing intent — locks the invariants before they regress.
* docs: Terminal flow + threat model + v1.1 follow-ups
SIDEBAR_MESSAGE_FLOW.md: new "Terminal flow" section. Documents the WS
upgrade path (/pty-session cookie mint → /ws Origin + cookie gate →
lazy claude spawn), the dual-token model (AUTH_TOKEN for /pty-session,
gstack_pty cookie for /ws, INTERNAL_TOKEN for server↔agent loopback),
and the threat-model boundary — the Terminal tab bypasses the entire
prompt-injection security stack on purpose; user keystrokes are the
trust source. That trust assumption is load-bearing on three transport
guarantees: local-only listener, Origin gate, cookie auth. Drop any
one of those three and the tab becomes unsafe.
CLAUDE.md: extends the "Sidebar architecture" note to include
terminal-agent.ts in the read-this-first list. Adds a "Terminal tab is
its own process" note so a future contributor doesn't bolt PTY logic
onto sidebar-agent.ts.
TODOS.md: three new follow-ups under a new "Sidebar Terminal" section:
- v1.1: PTY session survives sidebar reload (Issue 1C deferred).
- v1.1+: audit /health AUTH_TOKEN distribution (codex finding #2 —
a pre-existing soft leak that cc-pty-import sidesteps but doesn't
fix).
- v1.1+: apply terminal-agent's process.on exception handlers to
sidebar-agent.ts (codex finding #4 — chat path has no fatal
handlers).
* feat(extension): Terminal-only sidebar — auth fix, UX polish, chat rip
The chat queue path is gone. The Chrome side panel is now just an
interactive claude PTY in xterm.js. Activity / Refs / Inspector still
exist behind the `debug` toggle in the footer.
Three threads of change, all from dogfood iteration on top of
cc-pty-import:
1. fix(server): cross-port WS auth via Sec-WebSocket-Protocol
- Browsers can't set Authorization on a WebSocket upgrade. We had
been minting an HttpOnly gstack_pty cookie via /pty-session, but
SameSite=Strict cookies don't survive the cross-port jump from
server.ts:34567 to the agent's random port from a chrome-extension
origin. The WS opened then immediately closed → "Session ended."
- /pty-session now also returns ptySessionToken in the JSON body.
- Extension calls `new WebSocket(url, [`gstack-pty.<token>`])`.
Browser sends Sec-WebSocket-Protocol on the upgrade.
- Agent reads the protocol header, validates against validTokens,
and MUST echo the protocol back (Chromium closes the connection
immediately if a server doesn't pick one of the offered protocols).
- Cookie path is kept as a fallback for non-browser callers (curl,
integration tests).
- New integration test exercises the full protocol-auth round-trip
via raw fetch+Upgrade so a future regression of this exact class
fails in CI.
2. fix(extension): UX polish on the Terminal pane
- Eager auto-connect when the sidebar opens — no "Press any key to
start" friction every reload.
- Always-visible ↻ Restart button in the terminal toolbar (not
gated on the ENDED state) so the user can force a fresh claude
mid-session.
- MutationObserver on #tab-terminal's class attribute drives a
fitAddon.fit() + term.refresh() when the pane becomes visible
again — xterm doesn't auto-redraw after display:none → display:flex.
3. feat(extension): rip the chat tab + sidebar-agent.ts
- Sidebar is Terminal-only. No more Terminal | Chat primary nav.
- sidebar-agent.ts deleted. /sidebar-command, /sidebar-chat,
/sidebar-agent/event, /sidebar-tabs* and friends all deleted.
- The pickSidebarModel router (sonnet vs opus) is gone — the live
PTY uses whatever model the user's `claude` CLI is configured with.
- Quick-actions (🧹 Cleanup / 📸 Screenshot / 🍪 Cookies) survive
in the Terminal toolbar. Cleanup now injects its prompt into the
live PTY via window.gstackInjectToTerminal — no more
/sidebar-command POST. The Inspector "Send to Code" action uses
the same injection path.
- clear-chat button removed from the footer.
- sidepanel.js shed ~900 lines of chat polling, optimistic UI,
stop-agent, etc.
Net diff: -3.4k lines across 16 files. CLAUDE.md, TODOS.md, and
docs/designs/SIDEBAR_MESSAGE_FLOW.md rewritten to match. The sidebar
regression test (browse/test/sidebar-tabs.test.ts) is rewritten as 27
structural assertions locking the new layout — Terminal sole pane,
no chat input, quick-actions in toolbar, eager-connect, MutationObserver
repaint, restart helper.
* feat: live tab awareness for the Terminal pane
claude in the PTY now has continuous tab-aware context. Three pieces:
1. Live state files. background.js listens to chrome.tabs.onActivated /
onCreated / onRemoved / onUpdated (throttled to URL/title/status==
complete so loading spinners don't spam) and pushes a snapshot. The
sidepanel relays it as a custom event; sidepanel-terminal.js sends
{type:"tabState"} text frames over the live PTY WebSocket.
terminal-agent.ts writes:
<stateDir>/tabs.json all open tabs (id, url, title, active,
pinned, audible, windowId)
<stateDir>/active-tab.json current active tab (skips chrome:// and
chrome-extension:// internal pages)
Atomic write via tmp + rename so claude never reads a half-written
document. A fresh snapshot is pushed on WS open so the files exist by
the time claude finishes booting.
2. New $B tab-each <command> [args...] meta-command. Fans out a single
command across every open tab, returns
{command, args, total, results: [{tabId, url, title, status, output}]}.
Skips chrome:// pages; restores the originally active tab in a finally
block (so a mid-batch error doesn't leave the user looking at a
different tab); uses bringToFront: false so the OS window doesn't
jump on every fanout. Scope-checks the inner command BEFORE the loop.
3. --append-system-prompt hint at spawn time. Claude is told about both
the state files and the $B tab-each command up front, so it doesn't
have to discover the surface by trial. Passed via the --append-system-
prompt CLI flag, NOT as a leading PTY write — the hint stays out of
the visible transcript.
Tests:
- browse/test/tab-each.test.ts (new) — registration + source-level
invariants (scope check before loop, finally-restore, bringToFront:false,
chrome:// skip) + behavior tests with a mock BrowserManager that verify
iteration order, JSON shape, error handling, and active-tab restore.
- browse/test/terminal-agent.test.ts — three new assertions for
tabState handler shape, atomic-write pattern, and the
--append-system-prompt wiring at spawn.
Verified live: opened 5 tabs, ran $B tab-each url against the live
server, got per-tab JSON results back, original active tab restored
without OS focus stealing.
* chore: drop sidebar-agent test refs after chat rip
Five test files / describe blocks targeted the deleted chat path:
- browse/test/security-e2e-fullstack.test.ts (full-stack chat-pipeline E2E
with mock claude — whole file gone)
- browse/test/security-review-fullstack.test.ts (review-flow E2E with real
classifier — whole file gone)
- browse/test/security-review-sidepanel-e2e.test.ts (Playwright E2E for
the security event banner that was ripped from sidepanel.html)
- browse/test/security-audit-r2.test.ts (5 describe blocks: agent queue
permissions, isValidQueueEntry stateFile traversal, loadSession session-ID
validation, switchChatTab DocumentFragment, pollChat reentrancy guard,
/sidebar-tabs URL sanitization, sidebar-agent SIGTERM→SIGKILL escalation,
AGENT_SRC top-level read converted to graceful fallback)
- browse/test/security-adversarial-fixes.test.ts (canary stream-chunk split
detection on detectCanaryLeak; one tool-output test on sidebar-agent)
- test/skill-validation.test.ts (sidebar agent #584 describe block)
These all assumed sidebar-agent.ts existed and tested chat-queue plumbing,
chat-tab DOM round-trip, chat-polling reentrancy, or per-message classifier
canary detection. With the live PTY there is no chat queue, no chat tab,
no LLM stream to canary-scan, and no per-message subprocess. The Terminal
pane's invariants are covered by the new browse/test/sidebar-tabs.test.ts
(27 structural assertions), browse/test/terminal-agent.test.ts, and
browse/test/terminal-agent-integration.test.ts.
bun test → exit 0, 0 failures.
* chore: bump version and changelog (v1.14.0.0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(extension): xterm fills the full Terminal panel height
The Terminal pane only rendered into the top portion of the panel — most
of the panel below the prompt was an empty black gap. Three layered
issues, all about xterm.js measuring dimensions during a layout state
that wasn't ready yet:
1. order-of-operations in connect(): ensureXterm() ran BEFORE
setState(LIVE), so term.open() measured els.mount while it was still
display:none. xterm caches a 0-size viewport synchronously inside
open() and never auto-recovers when the container goes visible.
Flipped: setState(LIVE) → ensureXterm.
2. first fit() ran synchronously before the browser had applied the
.active class transition. Wrapped in requestAnimationFrame so layout
has settled before fit() reads clientHeight.
3. CSS flex-overflow trap: .terminal-mount has flex:1 inside the
flex-column #tab-terminal, but .tab-content's `overflow-y: auto` and
the lack of `min-height: 0` on .terminal-mount meant the item
couldn't shrink below content size. flex:1 then refused to expand
into available space and xterm rendered into whatever its initial
2x2 measurement happened to be.
Fixes:
- extension/sidepanel-terminal.js: reorder + RAF fit
- extension/sidepanel.css: .terminal-mount gets `flex: 1 1 0` +
`min-height: 0` + `position: relative`. #tab-terminal overrides
.tab-content's `overflow-y: auto` to `overflow: hidden` (xterm has
its own viewport scroll; the parent shouldn't compete) and explicitly
re-declares `display: flex; flex-direction: column` for #tab-terminal.active.
bun test browse/test/sidebar-tabs.test.ts → 27/27 pass.
Manually verified: side panel opens → Terminal fills full panel height,
xterm scrollback works, debug-tab toggle still repaints correctly.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
557 lines
22 KiB
TypeScript
557 lines
22 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* System-prompt hint passed to claude via --append-system-prompt. Tells
|
|
* claude what tab-awareness affordances exist in this session so it
|
|
* doesn't have to discover them by trial. The user can override anything
|
|
* here just by saying so — system prompt is a soft hint, not a contract.
|
|
*
|
|
* Two paths claude has:
|
|
* 1. Read live state from <stateDir>/tabs.json + active-tab.json
|
|
* (updated continuously by the gstack browser extension).
|
|
* 2. Run $B tab, $B tabs, $B tab-each <command> to act on tabs. The
|
|
* tab-each helper fans a single command across every open tab and
|
|
* returns per-tab results as JSON.
|
|
*/
|
|
function buildTabAwarenessHint(stateDir: string): string {
|
|
const tabsFile = path.join(stateDir, 'tabs.json');
|
|
const activeFile = path.join(stateDir, 'active-tab.json');
|
|
return [
|
|
'You are running inside the gstack browser sidebar with live access to the user\'s browser tabs.',
|
|
'',
|
|
'Tab state files (kept fresh automatically by the extension):',
|
|
` ${tabsFile} — all open tabs (id, url, title, active, pinned)`,
|
|
` ${activeFile} — the currently active tab`,
|
|
'Read these any time the user asks about "tabs", "the current page", or anything multi-tab. Do NOT shell out to $B tabs just to learn what\'s open — read the file.',
|
|
'',
|
|
'Tab manipulation commands (via $B):',
|
|
' $B tab <id> — switch to a tab',
|
|
' $B newtab [url] — open a new tab',
|
|
' $B closetab [id] — close a tab (current if no id)',
|
|
' $B tab-each <command> — fan out a command across every tab; returns JSON results',
|
|
'',
|
|
'When the user asks for multi-tab work, prefer $B tab-each. Examples:',
|
|
' $B tab-each snapshot -i — grab a snapshot from every tab',
|
|
' $B tab-each text — pull clean text from every tab',
|
|
' $B tab-each title — list every tab\'s title',
|
|
'',
|
|
'You\'re in a real terminal with a real PTY — slash commands, /resume, ANSI colors all work as in a normal claude session.',
|
|
].join('\n');
|
|
}
|
|
|
|
/** 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',
|
|
};
|
|
|
|
// --append-system-prompt is the right injection surface (per `claude --help`):
|
|
// it gets appended to the model's system prompt, so claude treats this as
|
|
// contextual guidance, not a user message. Don't use a leading PTY write
|
|
// for this — that would show up as if the user typed the hint, polluting
|
|
// the visible transcript.
|
|
const stateDir = path.dirname(STATE_FILE);
|
|
const tabHint = buildTabAwarenessHint(stateDir);
|
|
|
|
const proc = (Bun as any).spawn([claudePath, '--append-system-prompt', tabHint], {
|
|
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 — required, not optional.
|
|
// (2) Token must be in validTokens. We accept the token via two
|
|
// transports for compatibility:
|
|
// - Sec-WebSocket-Protocol (preferred for browsers — the only
|
|
// auth header settable from the browser WebSocket API)
|
|
// - Cookie gstack_pty (works for non-browser callers and
|
|
// same-port browser callers; doesn't survive the cross-port
|
|
// jump from server.ts:34567 to the agent's random port
|
|
// when SameSite=Strict is set)
|
|
// Either path works; both verify against the same in-memory
|
|
// validTokens Set, populated by the parent server's
|
|
// authenticated /pty-session → /internal/grant chain.
|
|
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 });
|
|
}
|
|
|
|
// Try Sec-WebSocket-Protocol first. Format: a single token, possibly
|
|
// with a `gstack-pty.` prefix (which we strip). Browsers send a
|
|
// comma-separated list when multiple were requested; we pick the
|
|
// first that matches a known token.
|
|
const protoHeader = req.headers.get('sec-websocket-protocol') || '';
|
|
let token: string | null = null;
|
|
let acceptedProtocol: string | null = null;
|
|
for (const raw of protoHeader.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
const candidate = raw.startsWith('gstack-pty.') ? raw.slice('gstack-pty.'.length) : raw;
|
|
if (validTokens.has(candidate)) {
|
|
token = candidate;
|
|
acceptedProtocol = raw;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Fallback: Cookie gstack_pty (legacy / non-browser callers).
|
|
if (!token) {
|
|
const cookieHeader = req.headers.get('cookie') || '';
|
|
for (const part of cookieHeader.split(';')) {
|
|
const [name, ...rest] = part.trim().split('=');
|
|
if (name === 'gstack_pty') {
|
|
const candidate = rest.join('=') || null;
|
|
if (candidate && validTokens.has(candidate)) {
|
|
token = candidate;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!token) {
|
|
return new Response('unauthorized', { status: 401 });
|
|
}
|
|
|
|
const upgraded = server.upgrade(req, {
|
|
data: { cookie: token },
|
|
// Echo the protocol back so the browser accepts the upgrade.
|
|
// Required when the client sends Sec-WebSocket-Protocol — the
|
|
// server MUST select one of the offered protocols, otherwise
|
|
// the browser closes the connection immediately.
|
|
...(acceptedProtocol ? { headers: { 'Sec-WebSocket-Protocol': acceptedProtocol } } : {}),
|
|
});
|
|
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;
|
|
}
|
|
if (msg?.type === 'tabState') {
|
|
handleTabState(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.
|
|
*/
|
|
/**
|
|
* Live tab snapshot. Writes <stateDir>/tabs.json (full list) and updates
|
|
* <stateDir>/active-tab.json (current active). claude can read these any
|
|
* time without invoking $B tabs — saves a round-trip when the model just
|
|
* needs to check the landscape before deciding what to do.
|
|
*/
|
|
function handleTabState(msg: {
|
|
active?: { tabId?: number; url?: string; title?: string } | null;
|
|
tabs?: Array<{ tabId?: number; url?: string; title?: string; active?: boolean; windowId?: number; pinned?: boolean; audible?: boolean }>;
|
|
reason?: string;
|
|
}): void {
|
|
const stateDir = path.dirname(STATE_FILE);
|
|
try { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); } catch {}
|
|
|
|
// tabs.json — full list
|
|
if (Array.isArray(msg.tabs)) {
|
|
const payload = {
|
|
updatedAt: new Date().toISOString(),
|
|
reason: msg.reason || 'unknown',
|
|
tabs: msg.tabs.map(t => ({
|
|
tabId: t.tabId ?? null,
|
|
url: t.url || '',
|
|
title: t.title || '',
|
|
active: !!t.active,
|
|
windowId: t.windowId ?? null,
|
|
pinned: !!t.pinned,
|
|
audible: !!t.audible,
|
|
})),
|
|
};
|
|
const target = path.join(stateDir, 'tabs.json');
|
|
const tmp = path.join(stateDir, `.tmp-tabs-${process.pid}`);
|
|
try {
|
|
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
fs.renameSync(tmp, target);
|
|
} catch {
|
|
safeUnlink(tmp);
|
|
}
|
|
}
|
|
|
|
// active-tab.json — single active tab. Skip chrome-internal pages so
|
|
// claude doesn't see chrome:// or chrome-extension:// URLs as
|
|
// "current target."
|
|
const active = msg.active;
|
|
if (active && active.url && !active.url.startsWith('chrome://') && !active.url.startsWith('chrome-extension://')) {
|
|
const ctxFile = path.join(stateDir, 'active-tab.json');
|
|
const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`);
|
|
try {
|
|
fs.writeFileSync(tmp, JSON.stringify({
|
|
tabId: active.tabId ?? null,
|
|
url: active.url,
|
|
title: active.title ?? '',
|
|
}), { mode: 0o600 });
|
|
fs.renameSync(tmp, ctxFile);
|
|
} catch {
|
|
safeUnlink(tmp);
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|