From ab4e9ae5207d1fcf766403027ed8989222bd48e6 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 14:11:36 -0700 Subject: [PATCH 1/8] refactor: remove browser sidebar extension Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/src/browser-manager.ts | 124 +-- bun.lock | 6 - extension/background.js | 608 ---------- extension/content.css | 124 --- extension/content.js | 378 ------- extension/icons/icon-128.png | Bin 2839 -> 0 bytes extension/icons/icon-16.png | Bin 400 -> 0 bytes extension/icons/icon-48.png | Bin 1106 -> 0 bytes extension/inspector.css | 29 - extension/inspector.js | 474 -------- extension/manifest.json | 31 - extension/popup.html | 98 -- extension/popup.js | 60 - extension/sidepanel-terminal.js | 1024 ----------------- extension/sidepanel.css | 1846 ------------------------------- extension/sidepanel.html | 202 ---- extension/sidepanel.js | 1415 ----------------------- package.json | 5 +- runtime/install.js | 3 +- scripts/build-app.sh | 7 - scripts/build.sh | 1 - 21 files changed, 12 insertions(+), 6423 deletions(-) delete mode 100644 extension/background.js delete mode 100644 extension/content.css delete mode 100644 extension/content.js delete mode 100644 extension/icons/icon-128.png delete mode 100644 extension/icons/icon-16.png delete mode 100644 extension/icons/icon-48.png delete mode 100644 extension/inspector.css delete mode 100644 extension/inspector.js delete mode 100644 extension/manifest.json delete mode 100644 extension/popup.html delete mode 100644 extension/popup.js delete mode 100644 extension/sidepanel-terminal.js delete mode 100644 extension/sidepanel.css delete mode 100644 extension/sidepanel.html delete mode 100644 extension/sidepanel.js diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 5cdfbb8ece..eaa187b6fb 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -17,7 +17,6 @@ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; import { readdirSync } from 'node:fs'; -import { writeSecureFile, mkdirSecure } from './file-permissions'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; import { emitActivity } from './activity'; import { validateNavigationUrl } from './url-validation'; @@ -243,9 +242,8 @@ export class BrowserManager { // back below. Pre-guardrail, nothing tracked tab count growth and a // user could accumulate hundreds of tabs (each holding 50–300 MB of // Chromium-side RSS) without warning until the OS OOM-killer fired. - // The toast UX lives in the sidebar (extension/sidepanel.js); the - // server-side responsibility is the audit-trail activity entry that - // appears in the activity feed even when the sidebar is closed. + // The server-side responsibility is the audit-trail activity entry that + // appears in the activity feed. private static readonly TAB_GUARDRAIL_SOFT = 50; private static readonly TAB_GUARDRAIL_HARD = 200; private tabGuardrailSoftHit = false; @@ -265,7 +263,7 @@ export class BrowserManager { } if (!this.tabGuardrailHardHit && total >= BrowserManager.TAB_GUARDRAIL_HARD) { this.tabGuardrailHardHit = true; - const msg = `Tab count crossed ${BrowserManager.TAB_GUARDRAIL_HARD} (now ${total}). OOM risk imminent. Open the sidebar to see top RAM consumers.`; + const msg = `Tab count crossed ${BrowserManager.TAB_GUARDRAIL_HARD} (now ${total}). OOM risk imminent. Close unused tabs to free RAM.`; console.error(`[browse] ${msg}`); emitActivity({ type: 'error', command: 'tab-guardrail', error: msg, tabs: total }); } @@ -320,43 +318,6 @@ export class BrowserManager { this.watchSnapshots.push(snapshot); } - /** - * Find the gstack Chrome extension directory. - * Checks: repo root /extension, global install, dev install. - */ - private findExtensionPath(): string | null { - const fs = require('fs'); - const path = require('path'); - const candidates = [ - // Explicit override via env var (used by GStack Browser.app bundle) - process.env.BROWSE_EXTENSIONS_DIR || '', - // Relative to this source file (dev mode: browse/src/ -> ../../extension) - path.resolve(__dirname, '..', '..', 'extension'), - // Global gstack install - path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'extension'), - // Git repo root (detected via BROWSE_STATE_FILE location) - (() => { - const stateFile = process.env.BROWSE_STATE_FILE || ''; - if (stateFile) { - const repoRoot = path.resolve(path.dirname(stateFile), '..'); - return path.join(repoRoot, '.claude', 'skills', 'gstack', 'extension'); - } - return ''; - })(), - ].filter(Boolean); - - for (const candidate of candidates) { - try { - if (fs.existsSync(path.join(candidate, 'manifest.json'))) { - return candidate; - } - } catch (err: any) { - if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err; - } - } - return null; - } - /** * Set the proxy config applied to chromium.launch() in launch() and * launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5 @@ -378,14 +339,9 @@ export class BrowserManager { } async launch() { - // ─── Extension Support ──────────────────────────────────── - // BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory. - // Extensions only work in headed mode, so we use an off-screen window. - const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR; - if (extensionsDir) assertHeadedBrowserProvider(); const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()]; - let useHeadless = true; + const useHeadless = true; const executablePath = configuredChromiumExecutable(); // Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which @@ -396,21 +352,6 @@ export class BrowserManager { launchArgs.push('--no-sandbox'); } - if (extensionsDir) { - // Skip --load-extension when running against a custom Chromium build that - // already bakes the extension in (e.g., GBrowser / GStack Browser.app). - // Loading it twice causes a ServiceWorkerState::SetWorkerId DCHECK crash. - if (!isCustomChromium()) { - launchArgs.push( - `--disable-extensions-except=${extensionsDir}`, - `--load-extension=${extensionsDir}`, - ); - } - launchArgs.push('--window-position=-9999,-9999', '--window-size=1,1'); - useHeadless = false; // extensions require headed mode; off-screen window simulates headless - console.log(`[browse] Extensions loaded from: ${extensionsDir}`); - } - this.browser = await chromium.launch({ headless: useHeadless, ...(executablePath @@ -468,23 +409,18 @@ export class BrowserManager { // ─── Headed Mode ───────────────────────────────────────────── /** - * Launch Playwright's bundled Chromium in headed mode with the gstack - * Chrome extension auto-loaded. Uses launchPersistentContext() which - * is required for extension loading (launch() + newContext() can't - * load extensions). + * Launch Playwright's bundled Chromium in headed mode. * * The browser launches headed with a visible window — the user sees * every action Claude takes in real time. */ - async launchHeaded(authToken?: string): Promise { + async launchHeaded(_authToken?: string): Promise { assertHeadedBrowserProvider(); // Clear old state before repopulating this.pages.clear(); this.tabSessions.clear(); this.nextTabId = 1; - // Find the gstack extension directory for auto-loading - const extensionPath = this.findExtensionPath(); const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); const launchArgs = [ '--hide-crash-restore-bubble', @@ -499,38 +435,9 @@ export class BrowserManager { // Chromium too. ...buildGStackLaunchArgs(), ]; - if (extensionPath) { - // Skip --load-extension when running against a custom Chromium build - // that already bakes the extension in as a component extension - // (gbrowser / GStack Browser.app). Loading it twice causes a - // ServiceWorkerState::SetWorkerId DCHECK crash. - if (!isCustomChromium()) { - launchArgs.push(`--disable-extensions-except=${extensionPath}`); - launchArgs.push(`--load-extension=${extensionPath}`); - } - // Write auth token for extension bootstrap (still required even when - // the extension is component-baked — it reads ~/.gstack/.auth.json at - // startup to learn how to call the daemon). - // Write to ~/.gstack/.auth.json (not the extension dir, which may be read-only - // in .app bundles and breaks codesigning). - if (authToken) { - const fs = require('fs'); - const path = require('path'); - const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack'); - mkdirSecure(gstackDir); - const authFile = path.join(gstackDir, '.auth.json'); - try { - writeSecureFile(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 })); - } catch (err: any) { - console.warn(`[browse] Could not write .auth.json: ${err.message}`); - } - } - } - // Launch headed Chromium via Playwright's persistent context. - // Extensions REQUIRE launchPersistentContext (not launch + newContext). - // Real Chrome (executablePath/channel) silently blocks --load-extension, - // so we use Playwright's bundled Chromium which reliably loads extensions. + // Launch headed Chromium via Playwright's persistent context so the + // profile (cookies, storage) persists across runs. const fs = require('fs'); const path = require('path'); const userDataDir = resolveChromiumProfile(); @@ -1600,27 +1507,16 @@ export class BrowserManager { const state = await this.saveState(); const currentUrl = this.getCurrentUrl(); - // 2. Launch new headed browser with extension (same as launchHeaded) - // Uses launchPersistentContext so the extension auto-loads. + // 2. Launch new headed browser (same as launchHeaded). + // Uses launchPersistentContext so the profile persists. let newContext: BrowserContext; try { const fs = require('fs'); - const path = require('path'); - const extensionPath = this.findExtensionPath(); const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); // Same blink-level stealth flags as launch()/launchHeaded(). Without // STEALTH_LAUNCH_ARGS the handed-off browser kept the AutomationControlled // tell that the other two paths strip. const launchArgs: string[] = ['--hide-crash-restore-bubble', ...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()]; - if (extensionPath) { - launchArgs.push(`--disable-extensions-except=${extensionPath}`); - launchArgs.push(`--load-extension=${extensionPath}`); - // Auth token is served via /health endpoint now (no file write needed). - // Extension reads token from /health on connect. - console.log(`[browse] Handoff: loading extension from ${extensionPath}`); - } else { - console.log('[browse] Handoff: extension not found — headed mode without side panel'); - } const userDataDir = resolveChromiumProfile(); fs.mkdirSync(userDataDir, { recursive: true }); diff --git a/bun.lock b/bun.lock index 720119df64..b5721849d3 100644 --- a/bun.lock +++ b/bun.lock @@ -13,8 +13,6 @@ "playwright": "npm:playwright-core@^1.58.2", "sharp": "^0.34.5", "socks": "^2.8.8", - "xterm": "5", - "xterm-addon-fit": "^0.8.0", }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.117", @@ -521,10 +519,6 @@ "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], - "xterm": ["xterm@5.3.0", "", {}, "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg=="], - - "xterm-addon-fit": ["xterm-addon-fit@0.8.0", "", { "peerDependencies": { "xterm": "^5.0.0" } }, "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], diff --git a/extension/background.js b/extension/background.js deleted file mode 100644 index f130a2076e..0000000000 --- a/extension/background.js +++ /dev/null @@ -1,608 +0,0 @@ -/** - * gstack browse — background service worker - * - * Polls /health every 10s to detect browse server. - * Fetches /refs on snapshot completion, relays to content script. - * Proxies commands from sidebar → browse server. - * Updates badge: amber (connected), gray (disconnected). - */ - -const DEFAULT_PORT = 34567; // Well-known port used by `$B connect` -let serverPort = null; -let authToken = null; -let isConnected = false; -let healthInterval = null; - -// ─── Port Discovery ──────────────────────────────────────────── - -async function loadPort() { - const data = await chrome.storage.local.get('port'); - serverPort = data.port || DEFAULT_PORT; - return serverPort; -} - -async function savePort(port) { - serverPort = port; - await chrome.storage.local.set({ port }); -} - -function getBaseUrl() { - return serverPort ? `http://127.0.0.1:${serverPort}` : null; -} - -// ─── Auth Token Bootstrap ───────────────────────────────────── - -async function loadAuthToken() { - if (authToken) return; - // Get token from browse server /health endpoint (localhost-only, safe). - // Previously read from .auth.json in extension dir, but that breaks - // read-only .app bundles and codesigning. - const base = getBaseUrl(); - if (!base) return; - try { - const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) }); - if (resp.ok) { - const data = await resp.json(); - if (data.token) authToken = data.token; - } - } catch (err) { - console.error('[gstack bg] Failed to load auth token:', err.message); - } -} - -// ─── Health Polling ──────────────────────────────────────────── - -async function checkHealth() { - const base = getBaseUrl(); - if (!base) { - setDisconnected(); - return; - } - - // Retry loading auth token if we don't have one yet - if (!authToken) await loadAuthToken(); - - try { - const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) }); - if (!resp.ok) { setDisconnected(); return; } - const data = await resp.json(); - if (data.status === 'healthy') { - // Always refresh auth token from /health — the server generates a new - // token on each restart, so the old one becomes stale. - if (data.token) authToken = data.token; - // Forward chatEnabled so sidepanel can show/hide chat tab - setConnected({ ...data, chatEnabled: !!data.chatEnabled }); - } else { - setDisconnected(); - } - } catch (err) { - console.error('[gstack bg] Health check failed:', err.message); - setDisconnected(); - } -} - -function setConnected(healthData) { - const wasDisconnected = !isConnected; - isConnected = true; - chrome.action.setBadgeBackgroundColor({ color: '#F59E0B' }); - chrome.action.setBadgeText({ text: ' ' }); - - // Broadcast health to popup and side panel (token excluded — use getToken message instead) - chrome.runtime.sendMessage({ type: 'health', data: healthData }).catch((err) => { - console.debug('[gstack bg] No listener for health broadcast:', err.message); - }); - - // Notify content scripts on connection change - if (wasDisconnected) { - notifyContentScripts('connected'); - } -} - -function setDisconnected() { - const wasConnected = isConnected; - isConnected = false; - // Keep authToken — it persists across reconnections - chrome.action.setBadgeText({ text: '' }); - - chrome.runtime.sendMessage({ type: 'health', data: null }).catch((err) => { - console.debug('[gstack bg] No listener for disconnect broadcast:', err.message); - }); - - // Notify content scripts on disconnection - if (wasConnected) { - notifyContentScripts('disconnected'); - } -} - -async function notifyContentScripts(type) { - try { - const tabs = await chrome.tabs.query({}); - for (const tab of tabs) { - if (tab.id) { - chrome.tabs.sendMessage(tab.id, { type }).catch(() => { - // Expected: tabs without content script - }); - } - } - } catch (err) { - console.error('[gstack bg] Failed to query tabs for notification:', err.message); - } -} - -// ─── Command Proxy ───────────────────────────────────────────── - -async function executeCommand(command, args) { - const base = getBaseUrl(); - if (!base || !authToken) { - return { error: 'Not connected to browse server' }; - } - - try { - const resp = await fetch(`${base}/command`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authToken}`, - }, - body: JSON.stringify({ command, args }), - signal: AbortSignal.timeout(30000), - }); - const data = await resp.json(); - return data; - } catch (err) { - return { error: err.message || 'Command failed' }; - } -} - -// ─── Refs Relay ───────────────────────────────────────────────── - -async function fetchAndRelayRefs() { - const base = getBaseUrl(); - if (!base || !isConnected) return; - - try { - const headers = {}; - if (authToken) headers['Authorization'] = `Bearer ${authToken}`; - const resp = await fetch(`${base}/refs`, { signal: AbortSignal.timeout(3000), headers }); - if (!resp.ok) { - console.warn(`[gstack bg] Refs endpoint returned ${resp.status}`); - return; - } - const data = await resp.json(); - - // Send to all tabs' content scripts - const tabs = await chrome.tabs.query({}); - for (const tab of tabs) { - if (tab.id) { - chrome.tabs.sendMessage(tab.id, { type: 'refs', data }).catch(() => { - // Expected: tabs without content script - }); - } - } - } catch (err) { - console.error('[gstack bg] Failed to fetch/relay refs:', err.message); - } -} - -// ─── Inspector ────────────────────────────────────────────────── - -// Track inspector mode per tab — 'full' (inspector.js injected) or 'basic' (content.js fallback) -let inspectorMode = 'full'; - -async function injectInspector(tabId) { - // Try full inspector injection first - try { - await chrome.scripting.executeScript({ - target: { tabId, allFrames: true }, - files: ['inspector.js'], - }); - // CSS injection failure alone doesn't need fallback - try { - await chrome.scripting.insertCSS({ - target: { tabId, allFrames: true }, - files: ['inspector.css'], - }); - } catch (err) { - console.debug('[gstack bg] Inspector CSS injection failed (non-fatal):', err.message); - } - // Send startPicker to the injected inspector.js - try { - await chrome.tabs.sendMessage(tabId, { type: 'startPicker' }); - } catch (err) { - console.warn('[gstack bg] Failed to send startPicker:', err.message); - } - inspectorMode = 'full'; - return { ok: true, mode: 'full' }; - } catch (err) { - // Script injection failed (CSP, chrome:// page, etc.) - // Fall back to content.js basic picker (loaded by manifest on most pages) - try { - await chrome.tabs.sendMessage(tabId, { type: 'startBasicPicker' }); - inspectorMode = 'basic'; - return { ok: true, mode: 'basic' }; - } catch (err2) { - console.error('[gstack bg] Inspector injection failed completely:', err.message, '| Basic fallback:', err2.message); - inspectorMode = 'full'; - return { error: 'Cannot inspect this page' }; - } - } -} - -async function stopInspector(tabId) { - try { - await chrome.tabs.sendMessage(tabId, { type: 'stopPicker' }); - } catch (err) { - console.debug('[gstack bg] Failed to stop picker on tab', tabId, ':', err.message); - } - return { ok: true }; -} - -async function postInspectorPick(selector, frameInfo, basicData, activeTabUrl) { - const base = getBaseUrl(); - if (!base || !authToken) { - // No browse server — return basic data as fallback - return { mode: 'basic', selector, basicData, frameInfo }; - } - - try { - const resp = await fetch(`${base}/inspector/pick`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authToken}`, - }, - body: JSON.stringify({ selector, activeTabUrl, frameInfo }), - signal: AbortSignal.timeout(10000), - }); - if (!resp.ok) { - // Server error — fall back to basic mode - return { mode: 'basic', selector, basicData, frameInfo }; - } - const data = await resp.json(); - return { mode: 'cdp', ...data }; - } catch (err) { - console.debug('[gstack bg] Inspector pick server unavailable, using basic mode:', err.message); - return { mode: 'basic', selector, basicData, frameInfo }; - } -} - -async function sendToContentScript(tabId, message) { - try { - const response = await chrome.tabs.sendMessage(tabId, message); - return response || { ok: true }; - } catch { - return { error: 'Content script not available' }; - } -} - -// ─── Message Handling ────────────────────────────────────────── - -chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { - // Security: only accept messages from this extension's own scripts - if (sender.id !== chrome.runtime.id) { - console.warn('[gstack] Rejected message from unknown sender:', sender.id); - return; - } - - const ALLOWED_TYPES = new Set([ - 'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs', - 'openSidePanel', 'sidebarOpened', 'command', 'sidebar-command', - 'getTabState', - // Inspector message types - 'startInspector', 'stopInspector', 'elementPicked', 'pickerCancelled', - 'applyStyle', 'toggleClass', 'injectCSS', 'resetAll', - 'inspectResult' - ]); - if (!ALLOWED_TYPES.has(msg.type)) { - console.warn('[gstack] Rejected unknown message type:', msg.type); - return; - } - - if (msg.type === 'getPort') { - const ownExtensionOrigin = `chrome-extension://${chrome.runtime.id}`; - sendResponse({ - port: serverPort, - connected: isConnected, - token: sender.origin === ownExtensionOrigin ? authToken : null, - }); - return true; - } - - if (msg.type === 'getTabState') { - snapshotTabs().then(snap => sendResponse(snap || { active: null, tabs: [] })); - return true; // async sendResponse - } - - if (msg.type === 'setPort') { - savePort(msg.port).then(() => { - checkHealth(); - sendResponse({ ok: true }); - }); - return true; - } - - if (msg.type === 'getServerUrl') { - sendResponse({ url: getBaseUrl() }); - return true; - } - - // Token delivered via targeted sendResponse, not broadcast — limits exposure. - // Only respond to extension pages (sidepanel/popup) — content scripts have - // sender.tab set, so reject those to prevent token access from injected contexts. - if (msg.type === 'getToken') { - if (sender.tab) { - console.warn('[gstack] Rejected getToken from content script context'); - sendResponse({ token: null }); - } else { - sendResponse({ token: authToken }); - } - return true; - } - - if (msg.type === 'fetchRefs') { - fetchAndRelayRefs().then(() => sendResponse({ ok: true })); - return true; - } - - // Open side panel from content script pill click - if (msg.type === 'openSidePanel') { - if (chrome.sidePanel?.open && sender.tab) { - chrome.sidePanel.open({ tabId: sender.tab.id }).catch((err) => { - console.warn('[gstack bg] Failed to open side panel:', err.message); - }); - } - return; - } - - // Sidebar opened — tell active tab's content script so the welcome page - // can hide its arrow hint. Only fires when the sidebar actually connects. - if (msg.type === 'sidebarOpened') { - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const tabId = tabs?.[0]?.id; - if (tabId) { - chrome.tabs.sendMessage(tabId, { type: 'sidebarOpened' }).catch(() => { - // Expected: tab may not have content script - }); - } - }); - return; - } - - // Inspector: inject + start picker - if (msg.type === 'startInspector') { - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const tabId = tabs?.[0]?.id; - if (!tabId) { sendResponse({ error: 'No active tab' }); return; } - injectInspector(tabId).then(result => sendResponse(result)); - }); - return true; - } - - // Inspector: stop picker - if (msg.type === 'stopInspector') { - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const tabId = tabs?.[0]?.id; - if (!tabId) { sendResponse({ error: 'No active tab' }); return; } - stopInspector(tabId).then(result => sendResponse(result)); - }); - return true; - } - - // Inspector: element picked by content script - if (msg.type === 'elementPicked') { - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const activeTabUrl = tabs?.[0]?.url || null; - const frameInfo = msg.frameSrc ? { frameSrc: msg.frameSrc, frameName: msg.frameName } : null; - postInspectorPick(msg.selector, frameInfo, msg.basicData, activeTabUrl) - .then(result => { - // Forward enriched result to sidepanel - chrome.runtime.sendMessage({ - type: 'inspectResult', - data: { - ...result, - selector: msg.selector, - tagName: msg.tagName, - classes: msg.classes, - id: msg.id, - dimensions: msg.dimensions, - basicData: msg.basicData, - frameInfo, - }, - }).catch((err) => { - console.warn('[gstack bg] Failed to forward inspectResult to sidepanel:', err.message); - }); - sendResponse({ ok: true }); - }); - }); - return true; - } - - // Inspector: picker cancelled - if (msg.type === 'pickerCancelled') { - chrome.runtime.sendMessage({ type: 'pickerCancelled' }).catch((err) => { - console.debug('[gstack bg] No listener for pickerCancelled:', err.message); - }); - return; - } - - // Inspector: route alteration commands to content script - if (msg.type === 'applyStyle' || msg.type === 'toggleClass' || msg.type === 'injectCSS' || msg.type === 'resetAll') { - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const tabId = tabs?.[0]?.id; - if (!tabId) { sendResponse({ error: 'No active tab' }); return; } - sendToContentScript(tabId, msg).then(result => sendResponse(result)); - }); - return true; - } - - // Sidebar → browse server command proxy - if (msg.type === 'command') { - executeCommand(msg.command, msg.args).then(result => sendResponse(result)); - return true; - } - - // Sidebar → Claude Code (file-based message queue) - if (msg.type === 'sidebar-command') { - const base = getBaseUrl(); - if (!base || !authToken) { - sendResponse({ error: 'Not connected' }); - return true; - } - // Capture the active tab's URL so the sidebar agent knows what page - // the user is actually looking at (Playwright's page.url() can be stale - // if the user navigated manually in headed mode). - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const activeTabUrl = tabs?.[0]?.url || null; - fetch(`${base}/sidebar-command`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authToken}`, - }, - body: JSON.stringify({ message: msg.message, activeTabUrl }), - }) - .then(r => { - if (!r.ok) { - console.error(`[gstack bg] sidebar-command failed: ${r.status} ${r.statusText}`); - return r.json().catch(() => ({ error: `Server returned ${r.status}` })); - } - return r.json(); - }) - .then(data => sendResponse(data)) - .catch(err => { - console.error('[gstack bg] sidebar-command error:', err.message); - sendResponse({ error: err.message }); - }); - }); - return true; - } -}); - -// ─── Side Panel ───────────────────────────────────────────────── - -// Click extension icon → open side panel directly (no popup) -if (chrome.sidePanel && chrome.sidePanel.setPanelBehavior) { - chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch((err) => { - console.warn('[gstack bg] Failed to set panel behavior:', err.message); - }); -} - -// Auto-open side panel with retry. chrome.sidePanel.open() can fail silently -// if the window/tab isn't fully ready yet. Retry up to 5 times with backoff. -async function autoOpenSidePanel() { - if (!chrome.sidePanel?.open) return; - for (let attempt = 0; attempt < 5; attempt++) { - try { - const wins = await chrome.windows.getAll({ windowTypes: ['normal'] }); - if (wins.length > 0) { - await chrome.sidePanel.open({ windowId: wins[0].id }); - console.log(`[gstack] Side panel opened on attempt ${attempt + 1}`); - return; // success - } - } catch (e) { - // May throw if window isn't ready or user gesture required - console.log(`[gstack] Side panel open attempt ${attempt + 1} failed:`, e.message); - } - // Backoff: 500ms, 1000ms, 2000ms, 3000ms, 5000ms - await new Promise(r => setTimeout(r, [500, 1000, 2000, 3000, 5000][attempt])); - } - console.log('[gstack] Side panel auto-open failed after 5 attempts'); -} - -// Fire on install/update -chrome.runtime.onInstalled.addListener(() => { - autoOpenSidePanel(); -}); - -// Fire on every service worker startup (covers persistent context reuse) -autoOpenSidePanel(); - -// ─── Tab Awareness ─────────────────────────────────────────────── -// Push live tab state to the sidepanel so claude in the Terminal pane -// always has up-to-date tabs.json + active-tab.json on disk. The -// sidepanel relays these to terminal-agent.ts over the live WebSocket; -// terminal-agent writes the files for claude to read. - -async function snapshotTabs() { - try { - const [active] = await chrome.tabs.query({ active: true, currentWindow: true }); - const all = await chrome.tabs.query({}); - const slim = all.map(t => ({ - tabId: t.id, - url: t.url || '', - title: t.title || '', - active: !!t.active, - windowId: t.windowId, - pinned: !!t.pinned, - audible: !!t.audible, - })); - return { - active: active ? { tabId: active.id, url: active.url || '', title: active.title || '' } : null, - tabs: slim, - }; - } catch { - return null; - } -} - -async function pushTabState(reason) { - const snapshot = await snapshotTabs(); - if (!snapshot) return; - chrome.runtime.sendMessage({ - type: 'browserTabState', - reason, - ...snapshot, - }).catch(() => {}); // expected: sidepanel may not be open -} - -chrome.tabs.onActivated.addListener((activeInfo) => { - // Keep the legacy event for any consumer still listening to it (the chat - // path is gone but the message type is harmless), and also fire the new - // unified state push so claude's tabs.json reflects the new active tab. - chrome.tabs.get(activeInfo.tabId, (tab) => { - if (chrome.runtime.lastError || !tab) return; - chrome.runtime.sendMessage({ - type: 'browserTabActivated', - tabId: activeInfo.tabId, - url: tab.url || '', - title: tab.title || '', - }).catch(() => {}); - }); - pushTabState('activated'); -}); - -chrome.tabs.onCreated.addListener(() => pushTabState('created')); -chrome.tabs.onRemoved.addListener(() => pushTabState('removed')); -chrome.tabs.onUpdated.addListener((_id, changeInfo) => { - // Throttle: only re-push on URL or title changes, not on every loading - // tick. We don't want to spam claude with a state push every 50ms while - // a page loads. - if (changeInfo.url || changeInfo.title || changeInfo.status === 'complete') { - pushTabState('updated'); - } -}); - -// ─── Startup ──────────────────────────────────────────────────── - -// Fast-retry health check on startup. The server may not be listening yet -// (Chromium launches before Bun.serve starts). Retry every 1s for the -// first 15 seconds, then switch to 10s polling. -loadAuthToken().then(() => { - loadPort().then(() => { - let startupAttempts = 0; - const startupCheck = setInterval(async () => { - startupAttempts++; - await checkHealth(); - if (isConnected || startupAttempts >= 15) { - clearInterval(startupCheck); - // Switch to slow polling now that we're connected (or gave up) - if (!healthInterval) { - healthInterval = setInterval(checkHealth, 10000); - } - if (!isConnected) { - console.log('[gstack] Startup health checks failed after 15 attempts, falling back to 10s polling'); - } - } - }, 1000); - }); -}); diff --git a/extension/content.css b/extension/content.css deleted file mode 100644 index 31d3f1ebbe..0000000000 --- a/extension/content.css +++ /dev/null @@ -1,124 +0,0 @@ -/* gstack browse — ref overlay + status pill styles - * Design system: DESIGN.md (amber accent, zinc neutrals) - */ - -#gstack-ref-overlays { - font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace !important; -} - -/* Connection status pill — bottom-right corner */ -#gstack-status-pill { - position: fixed; - bottom: 16px; - right: 16px; - z-index: 2147483646; - display: flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - background: rgba(12, 12, 12, 0.85); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: 1px solid rgba(245, 158, 11, 0.25); - border-radius: 9999px; - color: #e0e0e0; - font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; - font-size: 11px; - font-weight: 500; - letter-spacing: 0.02em; - pointer-events: auto; - cursor: pointer; - transition: opacity 0.5s ease; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4); -} - -#gstack-status-pill:hover { - opacity: 1 !important; -} - -.gstack-pill-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: #F59E0B; - box-shadow: 0 0 6px rgba(245, 158, 11, 0.5); - flex-shrink: 0; -} - -@media (prefers-reduced-motion: reduce) { - #gstack-status-pill { - transition: none; - } -} - -.gstack-ref-badge { - position: absolute; - background: rgba(220, 38, 38, 0.9); - color: #fff; - font-size: 10px; - font-weight: 700; - padding: 1px 4px; - border-radius: 4px; - line-height: 14px; - pointer-events: none; - z-index: 2147483647; -} - -/* Floating ref panel (used when positions are unknown) */ -.gstack-ref-panel { - position: fixed; - bottom: 12px; - right: 12px; - width: 220px; - max-height: 300px; - background: rgba(12, 12, 12, 0.95); - border: 1px solid #262626; - border-radius: 8px; - overflow: hidden; - pointer-events: auto; - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); - font-size: 11px; -} - -.gstack-ref-panel-header { - padding: 6px 10px; - background: #141414; - border-bottom: 1px solid #262626; - color: #FAFAFA; - font-weight: 600; - font-size: 11px; -} - -.gstack-ref-panel-list { - max-height: 260px; - overflow-y: auto; -} - -.gstack-ref-panel-row { - padding: 3px 10px; - border-bottom: 1px solid #1f1f1f; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.gstack-ref-panel-id { - color: #FBBF24; - font-weight: 600; - margin-right: 4px; -} - -.gstack-ref-panel-role { - color: #A1A1AA; - margin-right: 4px; -} - -.gstack-ref-panel-name { - color: #e0e0e0; -} - -.gstack-ref-panel-more { - padding: 4px 10px; - color: #52525B; - font-style: italic; -} diff --git a/extension/content.js b/extension/content.js deleted file mode 100644 index 8af84d7f71..0000000000 --- a/extension/content.js +++ /dev/null @@ -1,378 +0,0 @@ -/** - * gstack browse — content script - * - * Receives ref data from background worker via chrome.runtime.onMessage. - * Renders @ref overlay badges on the page (CDP mode only — positions are accurate). - * In headless mode, shows a floating ref panel instead (positions unknown). - */ - -let overlayContainer = null; -let statusPill = null; -let pillFadeTimer = null; -let refCount = 0; - -// ─── Connection Status Pill ────────────────────────────────── - -function showStatusPill(connected, refs) { - refCount = refs || 0; - - if (!statusPill) { - statusPill = document.createElement('div'); - statusPill.id = 'gstack-status-pill'; - statusPill.style.cursor = 'pointer'; - statusPill.addEventListener('click', () => { - // Ask background to open the side panel - chrome.runtime.sendMessage({ type: 'openSidePanel' }); - }); - document.body.appendChild(statusPill); - } - - if (!connected) { - statusPill.style.display = 'none'; - return; - } - - const refText = refCount > 0 ? ` · ${refCount} refs` : ''; - statusPill.innerHTML = ` gstack${refText}`; - statusPill.style.display = 'flex'; - statusPill.style.opacity = '1'; - - // Fade to subtle after 3s - clearTimeout(pillFadeTimer); - pillFadeTimer = setTimeout(() => { - statusPill.style.opacity = '0.3'; - }, 3000); -} - -function hideStatusPill() { - if (statusPill) { - statusPill.style.display = 'none'; - } -} - -function ensureContainer() { - if (overlayContainer) return overlayContainer; - overlayContainer = document.createElement('div'); - overlayContainer.id = 'gstack-ref-overlays'; - overlayContainer.style.cssText = 'position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647; pointer-events: none;'; - document.body.appendChild(overlayContainer); - return overlayContainer; -} - -function clearOverlays() { - if (overlayContainer) { - overlayContainer.innerHTML = ''; - } -} - -function renderRefBadges(refs) { - clearOverlays(); - if (!refs || refs.length === 0) return; - - const container = ensureContainer(); - - for (const ref of refs) { - // Try to find the element using accessible name/role for positioning - // In CDP mode, we could use bounding boxes from the server - // For now, use a floating panel approach - const badge = document.createElement('div'); - badge.className = 'gstack-ref-badge'; - badge.textContent = ref.ref; - badge.title = `${ref.role}: "${ref.name}"`; - container.appendChild(badge); - } -} - -function renderRefPanel(refs) { - clearOverlays(); - if (!refs || refs.length === 0) return; - - const container = ensureContainer(); - - const panel = document.createElement('div'); - panel.className = 'gstack-ref-panel'; - - const header = document.createElement('div'); - header.className = 'gstack-ref-panel-header'; - header.textContent = `gstack refs (${refs.length})`; - header.style.cssText = 'pointer-events: auto; cursor: move;'; - panel.appendChild(header); - - const list = document.createElement('div'); - list.className = 'gstack-ref-panel-list'; - for (const ref of refs.slice(0, 30)) { // Show max 30 in panel - const row = document.createElement('div'); - row.className = 'gstack-ref-panel-row'; - const idSpan = document.createElement('span'); - idSpan.className = 'gstack-ref-panel-id'; - idSpan.textContent = ref.ref; - const roleSpan = document.createElement('span'); - roleSpan.className = 'gstack-ref-panel-role'; - roleSpan.textContent = ref.role; - const nameSpan = document.createElement('span'); - nameSpan.className = 'gstack-ref-panel-name'; - nameSpan.textContent = '"' + ref.name + '"'; - row.append(idSpan, document.createTextNode(' '), roleSpan, document.createTextNode(' '), nameSpan); - list.appendChild(row); - } - if (refs.length > 30) { - const more = document.createElement('div'); - more.className = 'gstack-ref-panel-more'; - more.textContent = `+${refs.length - 30} more`; - list.appendChild(more); - } - panel.appendChild(list); - container.appendChild(panel); -} - -// ─── Basic Inspector Picker (CSP fallback) ────────────────── -// When inspector.js can't be injected (CSP, chrome:// pages), content.js -// provides a basic element picker using getComputedStyle + CSSOM. - -let basicPickerActive = false; -let basicPickerOverlay = null; -let basicPickerLastEl = null; -let basicPickerSavedOutline = ''; - -const BASIC_KEY_PROPERTIES = [ - 'display', 'position', 'top', 'right', 'bottom', 'left', - 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', - 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', - 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', - 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', - 'color', 'background-color', 'background-image', - 'font-family', 'font-size', 'font-weight', 'line-height', - 'text-align', 'text-decoration', - 'overflow', 'overflow-x', 'overflow-y', - 'opacity', 'z-index', - 'flex-direction', 'justify-content', 'align-items', 'flex-wrap', 'gap', - 'grid-template-columns', 'grid-template-rows', - 'box-shadow', 'border-radius', 'transform', -]; - -function captureBasicData(el) { - const computed = getComputedStyle(el); - const rect = el.getBoundingClientRect(); - - const computedStyles = {}; - for (const prop of BASIC_KEY_PROPERTIES) { - computedStyles[prop] = computed.getPropertyValue(prop); - } - - const boxModel = { - content: { width: rect.width, height: rect.height }, - padding: { - top: parseFloat(computed.paddingTop) || 0, - right: parseFloat(computed.paddingRight) || 0, - bottom: parseFloat(computed.paddingBottom) || 0, - left: parseFloat(computed.paddingLeft) || 0, - }, - border: { - top: parseFloat(computed.borderTopWidth) || 0, - right: parseFloat(computed.borderRightWidth) || 0, - bottom: parseFloat(computed.borderBottomWidth) || 0, - left: parseFloat(computed.borderLeftWidth) || 0, - }, - margin: { - top: parseFloat(computed.marginTop) || 0, - right: parseFloat(computed.marginRight) || 0, - bottom: parseFloat(computed.marginBottom) || 0, - left: parseFloat(computed.marginLeft) || 0, - }, - }; - - // Matched CSS rules via CSSOM (same-origin only) - const matchedRules = []; - try { - for (const sheet of document.styleSheets) { - try { - const rules = sheet.cssRules || sheet.rules; - if (!rules) continue; - for (const rule of rules) { - if (rule.type !== CSSRule.STYLE_RULE) continue; - try { - if (el.matches(rule.selectorText)) { - const properties = []; - for (let i = 0; i < rule.style.length; i++) { - const prop = rule.style[i]; - properties.push({ - name: prop, - value: rule.style.getPropertyValue(prop), - priority: rule.style.getPropertyPriority(prop), - }); - } - matchedRules.push({ - selector: rule.selectorText, - properties, - source: sheet.href || 'inline', - }); - } - } catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; } - } - } catch (e) { if (!(e instanceof DOMException)) throw e; } - } - } catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; } - - return { computedStyles, boxModel, matchedRules }; -} - -function basicBuildSelector(el) { - if (el.id) { - const sel = '#' + CSS.escape(el.id); - try { if (document.querySelectorAll(sel).length === 1) return sel; } catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; } - } - const parts = []; - let current = el; - while (current && current !== document.body && current !== document.documentElement) { - let part = current.tagName.toLowerCase(); - if (current.id) { - parts.unshift('#' + CSS.escape(current.id)); - break; - } - if (current.className && typeof current.className === 'string') { - const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0); - if (classes.length > 0) part += '.' + classes.map(c => CSS.escape(c)).join('.'); - } - const parent = current.parentElement; - if (parent) { - const siblings = Array.from(parent.children).filter(s => s.tagName === current.tagName); - if (siblings.length > 1) { - part += `:nth-child(${Array.from(parent.children).indexOf(current) + 1})`; - } - } - parts.unshift(part); - current = current.parentElement; - } - return parts.join(' > '); -} - -function basicPickerHighlight(el) { - // Restore previous element - if (basicPickerLastEl && basicPickerLastEl !== el) { - basicPickerLastEl.style.outline = basicPickerSavedOutline; - } - if (el) { - basicPickerSavedOutline = el.style.outline; - el.style.outline = '2px solid rgba(59, 130, 246, 0.6)'; - basicPickerLastEl = el; - } -} - -function basicPickerCleanup() { - if (basicPickerLastEl) { - basicPickerLastEl.style.outline = basicPickerSavedOutline; - basicPickerLastEl = null; - basicPickerSavedOutline = ''; - } - basicPickerActive = false; - document.removeEventListener('mousemove', onBasicMouseMove, true); - document.removeEventListener('click', onBasicClick, true); - document.removeEventListener('keydown', onBasicKeydown, true); -} - -function onBasicMouseMove(e) { - if (!basicPickerActive) return; - e.preventDefault(); - e.stopPropagation(); - const el = document.elementFromPoint(e.clientX, e.clientY); - if (el && el !== basicPickerLastEl) { - basicPickerHighlight(el); - } -} - -function onBasicClick(e) { - if (!basicPickerActive) return; - e.preventDefault(); - e.stopPropagation(); - const el = e.target; - - const basicData = captureBasicData(el); - const selector = basicBuildSelector(el); - const tagName = el.tagName.toLowerCase(); - const id = el.id || null; - const classes = el.className && typeof el.className === 'string' - ? el.className.trim().split(/\s+/).filter(c => c.length > 0) - : []; - - basicPickerCleanup(); - - chrome.runtime.sendMessage({ - type: 'inspectResult', - data: { - selector, - tagName, - id, - classes, - basicData, - mode: 'basic', - boxModel: basicData.boxModel, - computedStyles: basicData.computedStyles, - matchedRules: basicData.matchedRules, - }, - }); -} - -function onBasicKeydown(e) { - if (e.key === 'Escape') { - basicPickerCleanup(); - chrome.runtime.sendMessage({ type: 'pickerCancelled' }); - } -} - -function startBasicPicker() { - basicPickerActive = true; - document.addEventListener('mousemove', onBasicMouseMove, true); - document.addEventListener('click', onBasicClick, true); - document.addEventListener('keydown', onBasicKeydown, true); -} - -// Do NOT dispatch gstack-extension-ready here — the extension being loaded -// does not mean the sidebar is open. The welcome page arrow hint should only -// hide when the sidebar is actually open. We dispatch it when we receive -// a 'sidebarOpened' message from background.js. - -// Listen for messages from background worker -chrome.runtime.onMessage.addListener((msg) => { - // Sidebar actually opened — now hide the welcome page arrow hint - if (msg.type === 'sidebarOpened') { - document.dispatchEvent(new CustomEvent('gstack-extension-ready')); - return; - } - if (msg.type === 'startBasicPicker') { - startBasicPicker(); - return; - } - if (msg.type === 'stopBasicPicker') { - basicPickerCleanup(); - return; - } - if (msg.type === 'refs' && msg.data) { - const refs = msg.data.refs || []; - const mode = msg.data.mode; - - if (refs.length === 0) { - clearOverlays(); - showStatusPill(true, 0); - return; - } - - // CDP mode: could use bounding boxes (future) - // For now: floating panel for all modes - renderRefPanel(refs); - showStatusPill(true, refs.length); - } - - if (msg.type === 'clearRefs') { - clearOverlays(); - showStatusPill(true, 0); - } - - if (msg.type === 'connected') { - showStatusPill(true, refCount); - } - - if (msg.type === 'disconnected') { - hideStatusPill(); - clearOverlays(); - } -}); diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png deleted file mode 100644 index bad5e886df0830964948362882ea3df2d4565efe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2839 zcmb7Gc`(!u8~^TFH#rhNa^}cpMUGr07TF@_y6ao|G~%p>R*X+R~#_oiqZ^9#L0N$NFZYy=oYUvN(BO zP(HR-NBp6;XpE~$R^$}v#wSZcd(7h6bXE8_s#Rru{b3_DB7C5}b=$gKlwZ-kAJJF2 z46ce+#GsvdW<}Q2*dB_p3qt+^6gn?VYjs^+2>WtwmlmV$h36$^y^`<*J*_Tzurf~y zq{5cz1B^E@a>s&E*c?M7692d=>>JGTwiT&r>9`>1TA5?v2Gq52{2B!lMW1l9&>X`@ zNF#0YXXnc^iH#UQ`Doo$PG@FPqQnG}bD(b`*b{gq)OGSvl`yaSc6O65#GQ^(fk9Lt zE-gUl?WRGxlvwBZ2kTNvDmMKvzoDVQ@b*58mVb2#x+G&^VX;)tw>HJ0nM;Y9WwH9C zz?1sqm7!D?LgEAzpM^n4D4ZLMTUif}y)V|8W(^y;3qn9MoLOcJVhCvlpw2lqCq=2A z1?BlR2-z=el&6JS%ma^@ERd`7-4r$J9p{at(_rAZ5Tp7NEY&EK&@MzsQ|6Wbv985d z&Q*)SOq%{l3&!xu~rZ$`vB#NIA?{#)nLo!IFgPv(A zusht+ZsOCVaIT^G);#M_tDLSvb?-%Qq?H1itgBvv%Z7bFOcqSe1~IX1p%SC>;4WS$>X8pi?&lDjh3_qSQ*!$YLD0`}xT4Jm z_Q;H-7JARR4ppfMR#6Dd?xp*I#IbtBV>i>tIC{mZ`|*Qv6RdvwuWXjTecxUd;-K~$ zUlA*HVt-o$Z6ovjObtr5t!&1S&!Wgz^m6tKw+Y?mOa&i(x_8~T z@2kq;GahuCZ&H1(o)5N*m_qwk=!-s!>6=SCD>$HT6VT(y{|U#4kHLsIrp+x|99%n( z&SGjHS1PIGytl+mCpkYX{Cbak6u8^yyUYji?pgA1tB*8_JA0GZsAt0{upvWo-$e9D zrskSud!wETPlT# z_YP&hFX_13juX|eOF1>?Gj9L-4qR$qWQ0{C?W{1Opl!n*?+$k^lCWJe$JN3DVd*lG zQ`-SrZE24rgm}#xXE<1(2U(pLwvWa!yu8F$tn-FO{FxyfF5b*r2{plLD{W!!RR2* ziGpDV;*;XD&f6Zg4w<#6pbT_(2|1-WbY~lE`%}_Fjf%8H;k`zM%7p1;u;>9&7=lgB z<-u{ijZ_RPp6IY&`}BoTh~DsRz(NrfU*+iA-p{6J1WN+mdw+1?(|RYRWf4G7xj|yO z&0Pj5Mh-y;$-v{AG9V={$TF}mv&j@q-|k30M)YyLCm?Pn(51cxN*E@ zaea5#<*jK|4yJ$JDkQu}luxz#y=SPy z7mQL=JEoIi*|7t{)GVM3qB=wu&cQ@H*Z;Cc_T>%n@F_gps+50kkCL0qUkk{_!(@X+ zhKbC;Sb^V#?t+7)ROZ+E<|;0G*r@4zQBHc)#kMSsp;+YEbo-E$iD;16Y?FE1OzU37 z`0=WU5|8ux8M~Fg=sL$#%Z8f%So11mvhd0NRh6ZDv;cU5xuVrh+AabVsewH`SXHXU znjiSkt|wDqDKd6cRd6R^UOMNI@p{q1rv_MGxj+sC-qvhIC^>)QC}(o|80&C_1QEn% zT-{Hg60iUIWW#?FsDnmmBxD>)a$Gk2Q0(}%=yca740)PY0&F?%)&Om5=2fNj*L#%N z`LQk0lm4yj+cbVy^=Y!N^H;k%<8bkE(^ANoq$@!gR9R0vjY@@&C%|U%$cA`63Fq3 z&iYkkwKq6`Mu>yYzZX^!u*MURN7Dr3xyQ@kKnaLJj!ZUl6t!jkVxjf{6;aI#%+_#t zzsXXn?;=lnk3094)y3r0f`pbSpW^^AnPo59pX$I5;&_Ypx zrg5xA?&&^CYX8hPZ|+TM3P+KQJqr9N?{KMHqWbHqJvF~vUnb(-!sR<9qzEPXe( zj>2pIN*4^G5Vyy{$OfTj`FWkOoC{?%6~TUODOf2ETXl&yF~|;pC%9o4W}NRy zb3!L;DU1~$?kAnmI2?|v?O}PGn=}(V!KtRE<~pOkD_AH0yO~&L7?q||z+#;mbAEdp zr^iu?B87v$L!nCyvz>58cWI_WLfY;jm2!HG`YGcx`0fYmL^qpl%v)@{`xlkm@!H6h z=dm2eYz(xfK3n?{9w6UyFeM#FE_jo8OtG(z&(E-(nS-C7iH(gKSN!KYhkIOqfZRdR z5saQ+SfOH_bhN4AckbLdj*=r|8ifTn_+a$q9TJJSSk+B}&t#vion1)eaP;HpzZV3O z<1qDRy@}EMZLZEdZ-f1^c>JEVXgA-1C)Bh#aa`PESZp0h(c>(*_|283bb(uXM!My2 HXY79f!Omwz diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png deleted file mode 100644 index e0f7b060bea7523f37d78194dc6ff76a5b22f4db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 400 zcmV;B0dM|^P)?uvBfbDZXX}$9fT>&DnwQRD!WHRD|jINQqby27| ukJuH^&p4MF0FWOdpVLGnJ|H9~BJmH8t6|_Gbih(@Cwi9})+ zKmf#Iu`NP~Q$U@c3SH2nwQf!(lYfeMJRTk!8|&uQ5x+oBNl8hgWm(p9z|(%KF`#04 zditu=TDSSF#1tuIn*{FhTZk!I>lz6}{1)PYi1b^5SJn;qO<*+|Eeb<*g<^A56A5cl z(}Lk~xH2ladQIf@U3oJQwBYgkY+4&KJaCu7av0lR|B`&sE9vYG@Xw@oGorM#RC{ft zp;B?QHO=<2l{b}|g4e$aar~Tx@}R)8ftGrkH}<6o8II*==Ph3TG=#0p;yoqtLvh8M z`_07zJkeltWbd4Dys?3zFub)tO&||-taWFGJvFvbta0BRdN@P*M(5erKPY+c8;gNq zNu)s2RHZo3n4u_?{r@jKn&I>X%e4+813?Q8KA3TQezxD@nGZswGmC#a`J=^0hiBQ8 z<*0P`X2rJKH3P%$DwEL!XsA?_MzZ(*R9f)T$H7J77q~bmdH-AIKC8RKeI^@?z^-l1 z^Yv7(#mIznFTQgo$cN{GS)a|M-4{e71MwX7zU#N#gF0u=uL0R{zpxn@D9e$&e5?5kc=2=g?Q5 zoa2$|<%`|cO`6wQ(~fan3HNf0&jXLu+8jIREJo{jLEm4N%-Mn+<%-?+DmH|&zpg7G zd8#AqT8FU#OG4+fvovluPud&V_jZ`>OaIXn0W&c3-@33)lx7Z<>byXR=zWm6DbS#v|vs_~Zv~7bAHwut51n zO-sE^Q~&^;hI(=Yk_CyTVbMy^$y2nRH`6@k()I!RsxFJ)D3<`$UE z2>wp3<{SP8ccbeD{AS&N-$-D>ZxJ52CV@V`RhS}#=#xT-<9-Vfhdai3u-?%vm#bU8TQpz^1bq#Cp1Nejx;uobe Y0`2WmvSAIF8vp { - // Guard against double-injection - if (window.__gstackInspectorActive) return; - window.__gstackInspectorActive = true; - - // ─── State ────────────────────────────────────────────────────── - let pickerActive = false; - let highlightEl = null; - let tooltipEl = null; - let lastPickTime = 0; - const PICK_DEBOUNCE_MS = 200; - - // Track original inline styles for resetAll - const originalStyles = new Map(); // element -> Map - const injectedStyleIds = new Set(); - - // ─── Highlight Overlay ────────────────────────────────────────── - - function createHighlight() { - if (highlightEl) return; - - highlightEl = document.createElement('div'); - highlightEl.id = 'gstack-inspector-highlight'; - highlightEl.style.cssText = ` - position: fixed; - pointer-events: none; - z-index: 2147483647; - background: rgba(59, 130, 246, 0.15); - border: 2px solid rgba(59, 130, 246, 0.6); - border-radius: 2px; - transition: top 50ms, left 50ms, width 50ms, height 50ms; - `; - document.documentElement.appendChild(highlightEl); - - tooltipEl = document.createElement('div'); - tooltipEl.id = 'gstack-inspector-tooltip'; - tooltipEl.style.cssText = ` - position: fixed; - pointer-events: none; - z-index: 2147483647; - background: #27272A; - color: #e0e0e0; - font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; - font-size: 11px; - padding: 3px 8px; - border-radius: 4px; - white-space: nowrap; - box-shadow: 0 2px 8px rgba(0,0,0,0.4); - display: none; - `; - document.documentElement.appendChild(tooltipEl); - } - - function removeHighlight() { - if (highlightEl) { highlightEl.remove(); highlightEl = null; } - if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } - } - - function updateHighlight(el) { - if (!highlightEl || !tooltipEl) return; - const rect = el.getBoundingClientRect(); - - highlightEl.style.top = rect.top + 'px'; - highlightEl.style.left = rect.left + 'px'; - highlightEl.style.width = rect.width + 'px'; - highlightEl.style.height = rect.height + 'px'; - highlightEl.style.display = 'block'; - - // Build tooltip text: .classes WxH - const tag = el.tagName.toLowerCase(); - const classes = el.className && typeof el.className === 'string' - ? '.' + el.className.trim().split(/\s+/).join('.') - : ''; - const dims = `${Math.round(rect.width)}x${Math.round(rect.height)}`; - tooltipEl.textContent = `<${tag}> ${classes} ${dims}`.trim(); - - // Position tooltip above element, or below if no room - const tooltipHeight = 24; - const gap = 6; - let tooltipTop = rect.top - tooltipHeight - gap; - if (tooltipTop < 4) tooltipTop = rect.bottom + gap; - let tooltipLeft = rect.left; - if (tooltipLeft < 4) tooltipLeft = 4; - - tooltipEl.style.top = tooltipTop + 'px'; - tooltipEl.style.left = tooltipLeft + 'px'; - tooltipEl.style.display = 'block'; - } - - // ─── Selector Generation ──────────────────────────────────────── - - function buildSelector(el) { - // If element has an id, use it directly - if (el.id) { - const sel = '#' + CSS.escape(el.id); - if (isUnique(sel)) return sel; - } - - // Build path from element up to nearest ancestor with id or body - const parts = []; - let current = el; - - while (current && current !== document.body && current !== document.documentElement) { - let part = current.tagName.toLowerCase(); - - // If current has an id, use it and stop - if (current.id) { - part = '#' + CSS.escape(current.id); - parts.unshift(part); - break; - } - - // Add classes - if (current.className && typeof current.className === 'string') { - const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0); - if (classes.length > 0) { - part += '.' + classes.map(c => CSS.escape(c)).join('.'); - } - } - - // Add nth-child if needed to disambiguate - const parent = current.parentElement; - if (parent) { - const siblings = Array.from(parent.children).filter( - s => s.tagName === current.tagName - ); - if (siblings.length > 1) { - const idx = siblings.indexOf(current) + 1; - part += `:nth-child(${Array.from(parent.children).indexOf(current) + 1})`; - } - } - - parts.unshift(part); - current = current.parentElement; - } - - // If we didn't reach an id, prepend body - if (parts.length > 0 && !parts[0].startsWith('#')) { - // Don't prepend body, just use the path as-is - } - - const selector = parts.join(' > '); - - // Verify uniqueness - if (isUnique(selector)) return selector; - - // Fallback: add nth-child at each level until unique - return selector; - } - - function isUnique(selector) { - try { - return document.querySelectorAll(selector).length === 1; - } catch (e) { - if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; - return false; - } - } - - // ─── Basic Mode Data Capture ──────────────────────────────────── - - const KEY_PROPERTIES = [ - 'display', 'position', 'top', 'right', 'bottom', 'left', - 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', - 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', - 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', - 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', - 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style', - 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color', - 'color', 'background-color', 'background-image', - 'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing', - 'text-align', 'text-decoration', 'text-transform', - 'overflow', 'overflow-x', 'overflow-y', - 'opacity', 'z-index', - 'flex-direction', 'justify-content', 'align-items', 'flex-wrap', 'gap', - 'grid-template-columns', 'grid-template-rows', - 'box-shadow', 'border-radius', - 'transition', 'transform', - ]; - - function captureBasicData(el) { - const computed = getComputedStyle(el); - const rect = el.getBoundingClientRect(); - - // Capture key computed properties - const computedStyles = {}; - for (const prop of KEY_PROPERTIES) { - computedStyles[prop] = computed.getPropertyValue(prop); - } - - // Box model from computed - const boxModel = { - content: { width: rect.width, height: rect.height }, - padding: { - top: parseFloat(computed.paddingTop) || 0, - right: parseFloat(computed.paddingRight) || 0, - bottom: parseFloat(computed.paddingBottom) || 0, - left: parseFloat(computed.paddingLeft) || 0, - }, - border: { - top: parseFloat(computed.borderTopWidth) || 0, - right: parseFloat(computed.borderRightWidth) || 0, - bottom: parseFloat(computed.borderBottomWidth) || 0, - left: parseFloat(computed.borderLeftWidth) || 0, - }, - margin: { - top: parseFloat(computed.marginTop) || 0, - right: parseFloat(computed.marginRight) || 0, - bottom: parseFloat(computed.marginBottom) || 0, - left: parseFloat(computed.marginLeft) || 0, - }, - }; - - // Matched CSS rules via CSSOM (same-origin only) - const matchedRules = []; - try { - for (const sheet of document.styleSheets) { - try { - const rules = sheet.cssRules || sheet.rules; - if (!rules) continue; - for (const rule of rules) { - if (rule.type !== CSSRule.STYLE_RULE) continue; - try { - if (el.matches(rule.selectorText)) { - const properties = []; - for (let i = 0; i < rule.style.length; i++) { - const prop = rule.style[i]; - properties.push({ - name: prop, - value: rule.style.getPropertyValue(prop), - priority: rule.style.getPropertyPriority(prop), - }); - } - matchedRules.push({ - selector: rule.selectorText, - properties, - source: sheet.href || 'inline', - }); - } - } catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; } - } - } catch (e) { if (!(e instanceof DOMException)) throw e; } - } - } catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; } - - return { computedStyles, boxModel, matchedRules }; - } - - // ─── Picker Event Handlers ────────────────────────────────────── - - function onMouseMove(e) { - if (!pickerActive) return; - // Ignore our own overlay elements - const target = e.target; - if (target === highlightEl || target === tooltipEl) return; - if (target.id === 'gstack-inspector-highlight' || target.id === 'gstack-inspector-tooltip') return; - - updateHighlight(target); - } - - function onClick(e) { - if (!pickerActive) return; - - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - // Debounce - const now = Date.now(); - if (now - lastPickTime < PICK_DEBOUNCE_MS) return; - lastPickTime = now; - - const target = e.target; - if (target === highlightEl || target === tooltipEl) return; - if (target.id === 'gstack-inspector-highlight' || target.id === 'gstack-inspector-tooltip') return; - - const selector = buildSelector(target); - const basicData = captureBasicData(target); - - // Frame detection - const frameInfo = {}; - if (window !== window.top) { - try { - frameInfo.frameSrc = window.location.href; - frameInfo.frameName = window.name || null; - } catch (e) { if (!(e instanceof DOMException)) throw e; } - } - - chrome.runtime.sendMessage({ - type: 'elementPicked', - selector, - tagName: target.tagName.toLowerCase(), - classes: target.className && typeof target.className === 'string' - ? target.className.trim().split(/\s+/).filter(c => c.length > 0) - : [], - id: target.id || null, - dimensions: { - width: Math.round(target.getBoundingClientRect().width), - height: Math.round(target.getBoundingClientRect().height), - }, - basicData, - ...frameInfo, - }); - - // Keep highlight on the picked element - } - - function onKeyDown(e) { - if (!pickerActive) return; - if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - stopPicker(); - chrome.runtime.sendMessage({ type: 'pickerCancelled' }); - } - } - - // ─── Picker Start/Stop ────────────────────────────────────────── - - function startPicker() { - if (pickerActive) return; - pickerActive = true; - createHighlight(); - document.addEventListener('mousemove', onMouseMove, true); - document.addEventListener('click', onClick, true); - document.addEventListener('keydown', onKeyDown, true); - } - - function stopPicker() { - if (!pickerActive) return; - pickerActive = false; - removeHighlight(); - document.removeEventListener('mousemove', onMouseMove, true); - document.removeEventListener('click', onClick, true); - document.removeEventListener('keydown', onKeyDown, true); - } - - // ─── Page Alteration Handlers ─────────────────────────────────── - - function findElement(selector) { - try { - return document.querySelector(selector); - } catch (e) { - if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; - return null; - } - } - - function applyStyle(selector, property, value) { - // Validate property name: alphanumeric + hyphens only - if (!/^[a-zA-Z-]+$/.test(property)) return { error: 'Invalid property name' }; - // Validate CSS value: block exfiltration vectors (url(), expression(), @import, javascript:, data:) - if (/url\s*\(|expression\s*\(|@import|javascript:|data:/i.test(value)) { - return { error: 'CSS value contains blocked pattern' }; - } - - const el = findElement(selector); - if (!el) return { error: 'Element not found' }; - - // Track original value for resetAll - if (!originalStyles.has(el)) { - originalStyles.set(el, new Map()); - } - const origMap = originalStyles.get(el); - if (!origMap.has(property)) { - origMap.set(property, el.style.getPropertyValue(property)); - } - - el.style.setProperty(property, value, 'important'); - return { ok: true }; - } - - function toggleClass(selector, className, action) { - if (!/^[a-zA-Z0-9_-]+$/.test(className)) { - return { error: 'Invalid class name' }; - } - const el = findElement(selector); - if (!el) return { error: 'Element not found' }; - - if (action === 'add') { - el.classList.add(className); - } else if (action === 'remove') { - el.classList.remove(className); - } else { - el.classList.toggle(className); - } - return { ok: true }; - } - - function injectCSS(id, css) { - if (!/^[a-zA-Z0-9_-]+$/.test(id)) { - return { error: 'Invalid CSS injection id' }; - } - if (/url\s*\(|expression\s*\(|@import|javascript:|data:/i.test(css)) { - return { error: 'CSS contains blocked pattern (url, expression, @import)' }; - } - const styleId = `gstack-inject-${id}`; - let styleEl = document.getElementById(styleId); - if (!styleEl) { - styleEl = document.createElement('style'); - styleEl.id = styleId; - document.head.appendChild(styleEl); - } - styleEl.textContent = css; - injectedStyleIds.add(styleId); - return { ok: true }; - } - - function resetAll() { - // Restore original inline styles - for (const [el, propMap] of originalStyles) { - for (const [prop, origVal] of propMap) { - if (origVal) { - el.style.setProperty(prop, origVal); - } else { - el.style.removeProperty(prop); - } - } - } - originalStyles.clear(); - - // Remove injected style elements - for (const id of injectedStyleIds) { - const el = document.getElementById(id); - if (el) el.remove(); - } - injectedStyleIds.clear(); - - return { ok: true }; - } - - // ─── Message Listener ────────────────────────────────────────── - - chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { - if (msg.type === 'startPicker') { - startPicker(); - sendResponse({ ok: true }); - return; - } - if (msg.type === 'stopPicker') { - stopPicker(); - sendResponse({ ok: true }); - return; - } - if (msg.type === 'applyStyle') { - const result = applyStyle(msg.selector, msg.property, msg.value); - sendResponse(result); - return; - } - if (msg.type === 'toggleClass') { - const result = toggleClass(msg.selector, msg.className, msg.action); - sendResponse(result); - return; - } - if (msg.type === 'injectCSS') { - const result = injectCSS(msg.id, msg.css); - sendResponse(result); - return; - } - if (msg.type === 'resetAll') { - const result = resetAll(); - sendResponse(result); - return; - } - }); -})(); diff --git a/extension/manifest.json b/extension/manifest.json deleted file mode 100644 index 9625626464..0000000000 --- a/extension/manifest.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "manifest_version": 3, - "name": "gstack browse", - "version": "0.1.0", - "description": "Live activity feed and @ref overlays for gstack browse", - "permissions": ["sidePanel", "storage", "activeTab", "scripting", "tabs"], - "host_permissions": ["http://127.0.0.1:*/", "ws://127.0.0.1:*/"], - "action": { - "default_icon": { - "16": "icons/icon-16.png", - "48": "icons/icon-48.png", - "128": "icons/icon-128.png" - } - }, - "side_panel": { - "default_path": "sidepanel.html" - }, - "background": { - "service_worker": "background.js" - }, - "content_scripts": [{ - "matches": [""], - "js": ["content.js"], - "css": ["content.css"] - }], - "icons": { - "16": "icons/icon-16.png", - "48": "icons/icon-48.png", - "128": "icons/icon-128.png" - } -} diff --git a/extension/popup.html b/extension/popup.html deleted file mode 100644 index e9959915e0..0000000000 --- a/extension/popup.html +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - -

gstack

- - - - -
-
- Disconnected -
-
- - - - - - diff --git a/extension/popup.js b/extension/popup.js deleted file mode 100644 index 68fa25af0c..0000000000 --- a/extension/popup.js +++ /dev/null @@ -1,60 +0,0 @@ -const portInput = document.getElementById('port'); -const dot = document.getElementById('dot'); -const statusText = document.getElementById('status-text'); -const details = document.getElementById('details'); -const sidePanelBtn = document.getElementById('side-panel-btn'); - -// Load saved port -chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => { - if (resp && resp.port) { - portInput.value = resp.port; - updateStatus(resp.connected); - } -}); - -// Save port on change -let saveTimeout; -portInput.addEventListener('input', () => { - clearTimeout(saveTimeout); - saveTimeout = setTimeout(() => { - const port = parseInt(portInput.value, 10); - if (port > 0 && port < 65536) { - chrome.runtime.sendMessage({ type: 'setPort', port }); - } - }, 500); -}); - -// Listen for health updates -chrome.runtime.onMessage.addListener((msg) => { - if (msg.type === 'health') { - updateStatus(!!msg.data, msg.data); - } -}); - -function updateStatus(connected, data) { - dot.className = `dot ${connected ? 'connected' : ''}`; - statusText.className = `status-text ${connected ? 'connected' : ''}`; - statusText.textContent = connected ? 'Connected' : 'Disconnected'; - - if (connected && data) { - const parts = []; - if (data.tabs) parts.push(`${data.tabs} tabs`); - if (data.mode) parts.push(`Mode: ${data.mode}`); - details.textContent = parts.join(' \u00b7 '); - } else { - details.textContent = ''; - } -} - -// Open side panel -sidePanelBtn.addEventListener('click', async () => { - try { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (tab) { - await chrome.sidePanel.open({ tabId: tab.id }); - window.close(); - } - } catch (err) { - details.textContent = `Side panel error: ${err.message}`; - } -}); diff --git a/extension/sidepanel-terminal.js b/extension/sidepanel-terminal.js deleted file mode 100644 index e6287abcae..0000000000 --- a/extension/sidepanel-terminal.js +++ /dev/null @@ -1,1024 +0,0 @@ -/** - * Terminal sidebar tab — interactive Claude Code PTY in xterm.js. - * - * Lifecycle (per plan + codex review): - * 1. Sidebar opens. Terminal is the default-active tab. - * 2. Bootstrap card shows "Press any key to start Claude Code." - * 3. On first keystroke (lazy spawn — codex finding #8): the extension - * a) POSTs /pty-session on the browse server with the AUTH_TOKEN to - * mint a short-lived HttpOnly cookie scoped to the terminal-agent. - * b) Opens ws://127.0.0.1:/ws — the cookie travels - * automatically. Terminal-agent validates the cookie + the - * chrome-extension:// Origin (codex finding #9), then spawns - * claude in a PTY. - * 4. Bytes pump both ways. Resize observer sends {type:"resize"} text - * frames; tab-switch hooks send {type:"tabSwitch"} frames. - * 5. PTY exits or WS closes -> we show "Session ended" with a restart - * button. We do NOT auto-reconnect (codex finding #8: auto-reconnect - * = burn fresh claude session every time). - * - * Keep this file dependency-free. xterm.js + xterm-addon-fit are loaded - * via - - - - - diff --git a/extension/sidepanel.js b/extension/sidepanel.js deleted file mode 100644 index 5856ebdfb8..0000000000 --- a/extension/sidepanel.js +++ /dev/null @@ -1,1415 +0,0 @@ -/** - * gstack browse — Side Panel - * - * Terminal pane (default): live claude PTY via xterm.js, driven by - * sidepanel-terminal.js. The chat queue + sidebar-agent.ts were ripped - * in favor of the interactive REPL — no more one-shot claude -p. - * - * Debug tabs (behind the `debug` toggle): activity feed (SSE) + refs + - * inspector. Quick-actions toolbar (Cleanup / Screenshot / Cookies) - * lives at the top of the Terminal pane. - */ - -const NAV_COMMANDS = new Set(['goto', 'back', 'forward', 'reload']); -const INTERACTION_COMMANDS = new Set(['click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'upload']); -const OBSERVE_COMMANDS = new Set(['snapshot', 'screenshot', 'diff', 'console', 'network', 'text', 'html', 'links', 'forms', 'accessibility', 'cookies', 'storage', 'perf']); - -let lastId = 0; -let eventSource = null; -let serverUrl = null; -let serverToken = null; -let connState = 'disconnected'; // disconnected | connected | reconnecting | dead -let reconnectAttempts = 0; -let reconnectTimer = null; -const MAX_RECONNECT_ATTEMPTS = 30; // 30 * 2s = 60s before showing "dead" - -// Auth headers for sidebar endpoints -function authHeaders() { - const h = { 'Content-Type': 'application/json' }; - if (serverToken) h['Authorization'] = `Bearer ${serverToken}`; - return h; -} - -// ─── Connection State Machine ───────────────────────────────────── - -function setConnState(state) { - const prev = connState; - connState = state; - const banner = document.getElementById('conn-banner'); - const bannerText = document.getElementById('conn-banner-text'); - const bannerActions = document.getElementById('conn-banner-actions'); - - if (state === 'connected') { - if (prev === 'reconnecting' || prev === 'dead') { - // Show "reconnected" toast that fades - banner.style.display = ''; - banner.className = 'conn-banner reconnected'; - bannerText.textContent = 'Reconnected'; - bannerActions.style.display = 'none'; - setTimeout(() => { banner.style.display = 'none'; }, 5000); - } else { - banner.style.display = 'none'; - } - reconnectAttempts = 0; - if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; } - } else if (state === 'reconnecting') { - banner.style.display = ''; - banner.className = 'conn-banner reconnecting'; - bannerText.textContent = `Reconnecting... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`; - bannerActions.style.display = 'none'; - } else if (state === 'dead') { - banner.style.display = ''; - banner.className = 'conn-banner dead'; - bannerText.textContent = 'Server offline'; - bannerActions.style.display = ''; - if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; } - } else { - banner.style.display = 'none'; - } -} - -function startReconnect() { - if (reconnectTimer) return; - setConnState('reconnecting'); - reconnectTimer = setInterval(() => { - reconnectAttempts++; - if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) { - setConnState('dead'); - return; - } - setConnState('reconnecting'); - tryConnect(); - }, 2000); -} - - -// ─── Chat path ripped ──────────────────────────────────────────── -// Chat queue + sendMessage + pollChat + switchChatTab + browser-tabs -// strip + security banner all lived here. Replaced by the interactive -// claude PTY in sidepanel-terminal.js (and terminal-agent.ts on the -// server side). - -// ─── Reload Sidebar ───────────────────────────────────────────── -document.getElementById('reload-sidebar').addEventListener('click', () => { - location.reload(); -}); - -// ─── Copy Cookies ─────────────────────────────────────────────── -document.getElementById('chat-cookies-btn').addEventListener('click', async () => { - if (!serverUrl) return; - // Navigate the browser to the cookie picker page hosted by the browse server - try { - await fetch(`${serverUrl}/command`, { - method: 'POST', - headers: authHeaders(), - body: JSON.stringify({ command: 'goto', args: [`${serverUrl}/cookie-picker`] }), - }); - } catch (err) { - console.error('[gstack sidebar] Failed to open cookie picker:', err.message); - } -}); - -// ─── Debug Tabs ───────────────────────────────────────────────── - -const debugToggle = document.getElementById('debug-toggle'); -const debugTabs = document.getElementById('debug-tabs'); -const closeDebug = document.getElementById('close-debug'); -let debugOpen = false; - -// The Terminal pane is the only primary surface; Activity / Refs / Inspector -// are debug overlays behind the `debug` toggle. Closing debug returns to -// the Terminal pane, which is always present. -const PRIMARY_PANE_ID = 'tab-terminal'; - -function showPrimaryPane() { - document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - document.getElementById(PRIMARY_PANE_ID).classList.add('active'); - document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active')); -} - -debugToggle.addEventListener('click', () => { - debugOpen = !debugOpen; - debugToggle.classList.toggle('active', debugOpen); - debugTabs.style.display = debugOpen ? 'flex' : 'none'; - if (!debugOpen) showPrimaryPane(); -}); - -closeDebug.addEventListener('click', () => { - debugOpen = false; - debugToggle.classList.remove('active'); - debugTabs.style.display = 'none'; - showPrimaryPane(); -}); - -document.querySelectorAll('.debug-tabs .tab:not(.close-debug)').forEach(tab => { - tab.addEventListener('click', () => { - document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active')); - document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - tab.classList.add('active'); - document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active'); - - if (tab.dataset.tab === 'refs') fetchRefs(); - }); -}); - -// ─── Activity Feed ────────────────────────────────────────────── - -function getEntryClass(entry) { - if (entry.status === 'error') return 'error'; - if (entry.type === 'command_start') return 'pending'; - const cmd = entry.command || ''; - if (NAV_COMMANDS.has(cmd)) return 'nav'; - if (INTERACTION_COMMANDS.has(cmd)) return 'interaction'; - if (OBSERVE_COMMANDS.has(cmd)) return 'observe'; - return ''; -} - -function formatTime(ts) { - const d = new Date(ts); - return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); -} - -let pendingEntries = new Map(); - -function createEntryElement(entry) { - const div = document.createElement('div'); - div.className = `activity-entry ${getEntryClass(entry)}`; - div.setAttribute('role', 'article'); - div.tabIndex = 0; - - const argsText = entry.args ? entry.args.join(' ') : ''; - const statusIcon = entry.status === 'ok' ? '\u2713' : entry.status === 'error' ? '\u2717' : ''; - const statusClass = entry.status === 'ok' ? 'ok' : entry.status === 'error' ? 'err' : ''; - const duration = entry.duration ? `${entry.duration}ms` : ''; - - div.innerHTML = ` -
- ${formatTime(entry.timestamp)} - ${escapeHtml(entry.command || entry.type)} -
- ${argsText ? `
${escapeHtml(argsText)}
` : ''} - ${entry.type === 'command_end' ? ` -
- ${statusIcon} - ${duration} -
- ` : ''} - ${entry.result ? ` -
-
${escapeHtml(entry.result)}
-
- ` : ''} - `; - - div.addEventListener('click', () => div.classList.toggle('expanded')); - return div; -} - -function addEntry(entry) { - const feed = document.getElementById('activity-feed'); - const empty = document.getElementById('empty-state'); - if (empty) empty.style.display = 'none'; - - if (entry.type === 'command_end') { - for (const [id, el] of pendingEntries) { - if (el.querySelector('.entry-command')?.textContent === entry.command) { - el.remove(); - pendingEntries.delete(id); - break; - } - } - } - - const el = createEntryElement(entry); - feed.appendChild(el); - if (entry.type === 'command_start') pendingEntries.set(entry.id, el); - el.scrollIntoView({ behavior: 'smooth', block: 'end' }); - - if (entry.url) document.getElementById('footer-url')?.textContent && (document.getElementById('footer-url').textContent = new URL(entry.url).hostname); - lastId = Math.max(lastId, entry.id); -} - -function escapeHtml(str) { - const div = document.createElement('div'); - div.textContent = str; - // DOM text-node serialization escapes &, <, > but NOT " or '. Call sites - // that interpolate escapeHtml output inside an attribute value (title="...", - // data-x="...") need those escaped too or an attacker-controlled value can - // break out of the attribute. Add both manually. - return div.innerHTML - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -// ─── SSE Connection ───────────────────────────────────────────── - -// Fetch a view-only SSE session cookie before opening EventSource. -// EventSource can't send Authorization headers, and putting the root -// token in the URL (the old ?token= path) leaks it to logs, referer -// headers, and browser history. POST /sse-session issues an HttpOnly -// SameSite=Strict cookie scoped to SSE reads only; withCredentials:true -// on EventSource makes the browser send it back. -async function ensureSseSessionCookie() { - if (!serverUrl || !serverToken) return false; - try { - const resp = await fetch(`${serverUrl}/sse-session`, { - method: 'POST', - credentials: 'include', - headers: { 'Authorization': `Bearer ${serverToken}` }, - }); - return resp.ok; - } catch (err) { - console.warn('[gstack sidebar] Failed to mint SSE session cookie:', err && err.message); - return false; - } -} - -async function connectSSE() { - if (!serverUrl) return; - if (eventSource) { eventSource.close(); eventSource = null; } - - await ensureSseSessionCookie(); - const url = `${serverUrl}/activity/stream?after=${lastId}`; - eventSource = new EventSource(url, { withCredentials: true }); - - eventSource.addEventListener('activity', (e) => { - try { addEntry(JSON.parse(e.data)); } catch (err) { - console.error('[gstack sidebar] Failed to parse activity event:', err.message); - } - }); - - eventSource.addEventListener('gap', (e) => { - try { - const data = JSON.parse(e.data); - const feed = document.getElementById('activity-feed'); - const banner = document.createElement('div'); - banner.className = 'gap-banner'; - banner.textContent = `Missed ${data.availableFrom - data.gapFrom} events`; - feed.appendChild(banner); - } catch (err) { - console.error('[gstack sidebar] Failed to parse gap event:', err.message); - } - }); -} - -// ─── Memory Footer Readout ────────────────────────────────────── -// -// Polls /memory every 30s and renders "RSS: 1.4 GB · 12 tabs" in the -// footer. Backs off to 5min if a poll takes > 2s (Codex flag — diagnostic -// shouldn't add load when the browser is already unhealthy). Uses Bearer -// auth like /refs above; /memory is a plain GET so EventSource semantics -// don't apply. - -const MEM_POLL_FAST_MS = 30_000; -const MEM_POLL_SLOW_MS = 5 * 60_000; -const MEM_POLL_TIMEOUT_MS = 8_000; -const MEM_POLL_SLOW_THRESHOLD_MS = 2_000; -let memPollTimer = null; -let memPollMode = 'fast'; // 'fast' | 'slow' - -function fmtBytesShort(n) { - if (typeof n !== 'number' || isNaN(n)) return '?'; - if (n < 1024) return n + ' B'; - if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB'; - if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(0) + ' MB'; - return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'; -} - -function renderMemFooter(snapshot) { - const el = document.getElementById('footer-mem'); - if (!el) return; - const bunRss = snapshot?.bunServer?.rss ?? 0; - const tabCount = Array.isArray(snapshot?.tabs) ? snapshot.tabs.length : 0; - el.textContent = `${fmtBytesShort(bunRss)} · ${tabCount} tabs`; - // Color thresholds: ~2 GB Bun RSS or 50 tabs is "watch this"; ~8 GB or - // 200 tabs is "this is the cliff" (matches the 200-tab guardrail). - el.classList.remove('warn', 'bad'); - if (bunRss > 8 * 1024 * 1024 * 1024 || tabCount > 200) el.classList.add('bad'); - else if (bunRss > 2 * 1024 * 1024 * 1024 || tabCount > 50) el.classList.add('warn'); -} - -async function pollMemoryOnce() { - if (!serverUrl || !serverToken) return { ok: false, slow: false }; - const start = Date.now(); - try { - const resp = await fetch(`${serverUrl}/memory`, { - headers: { 'Authorization': `Bearer ${serverToken}` }, - signal: AbortSignal.timeout(MEM_POLL_TIMEOUT_MS), - credentials: 'include', - }); - const elapsed = Date.now() - start; - if (!resp.ok) return { ok: false, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS }; - const snapshot = await resp.json(); - renderMemFooter(snapshot); - // Evaluate guardrail triggers (single-heavy-tab OR tab-count crossing 200). - // Toast is hidden when no trigger fires; snooze state suppresses re-fire. - try { evaluateMemToast(snapshot); } catch (err) { - console.debug('[gstack sidebar] mem-toast evaluation failed:', err && err.message); - } - return { ok: true, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS }; - } catch (err) { - const elapsed = Date.now() - start; - // Don't log every poll failure — common during browser restarts / restoring - // sessions. Only log on the slow path so the user sees something in the - // console if the diagnostic itself is misbehaving. - if (elapsed > MEM_POLL_SLOW_THRESHOLD_MS) { - console.debug('[gstack sidebar] /memory poll slow/failed:', elapsed, 'ms', err && err.message); - } - return { ok: false, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS }; - } -} - -function scheduleNextMemPoll(delayMs) { - if (memPollTimer) clearTimeout(memPollTimer); - memPollTimer = setTimeout(async () => { - const { ok, slow } = await pollMemoryOnce(); - if (!ok || slow) { - memPollMode = 'slow'; - scheduleNextMemPoll(MEM_POLL_SLOW_MS); - } else { - // Successful + fast → back to fast cadence. - if (memPollMode === 'slow') memPollMode = 'fast'; - scheduleNextMemPoll(MEM_POLL_FAST_MS); - } - }, delayMs); -} - -function startMemPolling() { - if (memPollTimer) return; // already running - // Kick off an immediate poll so the footer populates within ~1s of sidebar - // open, instead of waiting 30s for the first cycle. - scheduleNextMemPoll(500); -} - -function stopMemPolling() { - if (memPollTimer) { - clearTimeout(memPollTimer); - memPollTimer = null; - } -} - -// ─── Tab guardrail toast (D5 + Codex single-tab flag) ─────── -// -// Each /memory poll evaluates two trigger conditions: -// 1. Tab count crossed 200 — show "top 5 tabs by max(jsHeap, ...)" with -// Close-selected + Snooze. -// 2. Any single tab over 4 GB JS heap — show one-tab toast (catches the -// Codex case where a runaway WebGL/video page balloons one tab). -// Snooze persists in chrome.storage.session: next warn fires at tabCount + -// snoozeBumpTabs OR when a single tab crosses (snoozedJsHeapBytes + 1). -// -// "Close selected" runs $B closetab via the existing /command path — -// no chrome.tabs.remove bridge needed. - -const HEAVY_TAB_HEAP_BYTES = 4 * 1024 * 1024 * 1024; // 4 GB per Codex flag -const TOAST_SNOOZE_TAB_BUMP = 50; // re-warn at 200+50 -const TOAST_SNOOZE_HEAP_BUMP = 2 * 1024 * 1024 * 1024; - -const memToastSnooze = { - tabsAbove: 0, // suppress the count-toast until tabs strictly exceeds this - heapAbove: 0, // suppress the single-tab toast until heap strictly exceeds this -}; - -async function loadSnoozeState() { - if (!chrome?.storage?.session) return; - try { - const stored = await chrome.storage.session.get(['memToastSnooze']); - if (stored?.memToastSnooze) { - memToastSnooze.tabsAbove = stored.memToastSnooze.tabsAbove | 0; - memToastSnooze.heapAbove = stored.memToastSnooze.heapAbove | 0; - } - } catch (err) { - console.debug('[gstack sidebar] mem-toast snooze load failed:', err && err.message); - } -} - -async function saveSnoozeState() { - if (!chrome?.storage?.session) return; - try { - await chrome.storage.session.set({ memToastSnooze: { ...memToastSnooze } }); - } catch (err) { - console.debug('[gstack sidebar] mem-toast snooze save failed:', err && err.message); - } -} - -function dismissMemToast() { - const toast = document.getElementById('mem-toast'); - if (toast) toast.style.display = 'none'; -} - -/** - * Sort key for "RAM-heavy" tabs. JS heap × 4 is a rough proxy for total - * tab footprint (renderers tend to spend ~4× their JS heap on native + - * Skia + cache); when a tab is heavy via WebGL/video the JS heap is - * small but listeners/nodes spike. Take the max. - */ -function tabRamScore(tab) { - const heap = tab?.jsHeapUsed || 0; - const nodes = tab?.nodes || 0; - const listeners = tab?.listeners || 0; - // ~1 KB per DOM node + ~200 bytes per listener as a back-of-envelope - // native-memory estimate. Keeps the sort meaningful when JS heap is small. - const nativeEstimate = nodes * 1024 + listeners * 200; - return Math.max(heap, nativeEstimate); -} - -function showMemToast(title, body, tabsForClose) { - const toast = document.getElementById('mem-toast'); - const titleEl = document.getElementById('mem-toast-title'); - const bodyEl = document.getElementById('mem-toast-body'); - const closeBtn = document.getElementById('mem-toast-close-selected'); - if (!toast || !titleEl || !bodyEl || !closeBtn) return; - - titleEl.textContent = title; - bodyEl.innerHTML = ''; - - for (const t of tabsForClose) { - const row = document.createElement('div'); - row.className = 'mem-toast-row'; - const cb = document.createElement('input'); - cb.type = 'checkbox'; - cb.id = `mem-toast-tab-${t.id}`; - cb.value = String(t.id); - cb.checked = true; // default-selected so a fast user just hits Close - const label = document.createElement('label'); - label.htmlFor = cb.id; - const urlShort = (t.url || '').length > 50 ? t.url.slice(0, 47) + '...' : (t.url || '(no url)'); - label.textContent = `tab #${t.id} — ${urlShort}`; - const size = document.createElement('span'); - size.className = 'mem-toast-size'; - size.textContent = fmtBytesShort(tabRamScore(t)); - row.appendChild(cb); - row.appendChild(label); - row.appendChild(size); - bodyEl.appendChild(row); - } - - toast.style.display = ''; - - closeBtn.onclick = async () => { - const ids = tabsForClose - .filter((t) => document.getElementById(`mem-toast-tab-${t.id}`)?.checked) - .map((t) => t.id); - dismissMemToast(); - for (const id of ids) { - try { - await fetch(`${serverUrl}/command`, { - method: 'POST', - headers: authHeaders(), - body: JSON.stringify({ command: 'closetab', args: [String(id)] }), - }); - } catch (err) { - console.warn('[gstack sidebar] mem-toast closetab failed:', id, err && err.message); - } - } - }; -} - -/** - * Driven by every successful /memory poll. Decides whether to surface - * the toast and which payload to show. - */ -function evaluateMemToast(snapshot) { - if (!snapshot || !Array.isArray(snapshot.tabs)) return; - const tabs = snapshot.tabs; - - // Trigger 1: any single tab over 4 GB JS heap. Catches the WebGL/video - // case before the tab count threshold ever fires. - const heavyTab = tabs.find((t) => (t.jsHeapUsed || 0) > HEAVY_TAB_HEAP_BYTES); - if (heavyTab && (heavyTab.jsHeapUsed || 0) > memToastSnooze.heapAbove) { - showMemToast( - `Heavy tab: ${fmtBytesShort(heavyTab.jsHeapUsed)} JS heap`, - '', - [heavyTab], - ); - return; - } - - // Trigger 2: tab count crossed the hard guardrail (200) and isn't snoozed. - if (tabs.length >= 200 && tabs.length > memToastSnooze.tabsAbove) { - const top5 = [...tabs].sort((a, b) => tabRamScore(b) - tabRamScore(a)).slice(0, 5); - showMemToast( - `${tabs.length} tabs open — close some?`, - '', - top5, - ); - return; - } - - // No trigger: keep toast hidden. -} - -function setupMemToastWiring() { - const close = document.getElementById('mem-toast-close'); - if (close) close.addEventListener('click', dismissMemToast); - const snooze = document.getElementById('mem-toast-snooze'); - if (snooze) { - snooze.addEventListener('click', async () => { - // Snooze logic: bump the thresholds above the current snapshot so the - // toast won't re-fire until the user has accumulated MORE tabs or one - // tab has grown ANOTHER 2 GB beyond what we just warned about. Stored - // in chrome.storage.session so a sidebar reload doesn't lose the - // snooze (but a Chrome restart does). - try { - const resp = await fetch(`${serverUrl}/memory`, { - headers: { 'Authorization': `Bearer ${serverToken}` }, - signal: AbortSignal.timeout(MEM_POLL_TIMEOUT_MS), - credentials: 'include', - }); - if (resp.ok) { - const snap = await resp.json(); - const tabs = Array.isArray(snap.tabs) ? snap.tabs : []; - memToastSnooze.tabsAbove = tabs.length + TOAST_SNOOZE_TAB_BUMP; - const maxHeap = tabs.reduce((m, t) => Math.max(m, t.jsHeapUsed || 0), 0); - memToastSnooze.heapAbove = maxHeap + TOAST_SNOOZE_HEAP_BUMP; - await saveSnoozeState(); - } - } catch (err) { - console.debug('[gstack sidebar] mem-toast snooze fetch failed:', err && err.message); - } - dismissMemToast(); - }); - } - void loadSnoozeState(); -} - -// Wire the toast on DOM ready. -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', setupMemToastWiring); -} else { - setupMemToastWiring(); -} - -// ─── Refs Tab ─────────────────────────────────────────────────── - -async function fetchRefs() { - if (!serverUrl) return; - try { - const headers = {}; - if (serverToken) headers['Authorization'] = `Bearer ${serverToken}`; - const resp = await fetch(`${serverUrl}/refs`, { signal: AbortSignal.timeout(3000), headers }); - if (!resp.ok) return; - const data = await resp.json(); - - const list = document.getElementById('refs-list'); - const empty = document.getElementById('refs-empty'); - const footer = document.getElementById('refs-footer'); - - if (!data.refs || data.refs.length === 0) { - empty.style.display = ''; - list.innerHTML = ''; - footer.textContent = ''; - return; - } - - empty.style.display = 'none'; - list.innerHTML = data.refs.map(r => ` -
- ${escapeHtml(r.ref)} - ${escapeHtml(r.role)} - "${escapeHtml(r.name)}" -
- `).join(''); - footer.textContent = `${data.refs.length} refs`; - } catch (err) { - console.error('[gstack sidebar] Failed to fetch refs:', err.message); - } -} - -// ─── Inspector Tab ────────────────────────────────────────────── - -let inspectorPickerActive = false; -let inspectorData = null; // last inspect result -let inspectorModifications = []; // tracked style changes -let inspectorSSE = null; - -// Inspector DOM refs -const inspectorPickBtn = document.getElementById('inspector-pick-btn'); -const inspectorSelected = document.getElementById('inspector-selected'); -const inspectorModeBadge = document.getElementById('inspector-mode-badge'); -const inspectorEmpty = document.getElementById('inspector-empty'); -const inspectorLoading = document.getElementById('inspector-loading'); -const inspectorError = document.getElementById('inspector-error'); -const inspectorPanels = document.getElementById('inspector-panels'); -const inspectorBoxmodel = document.getElementById('inspector-boxmodel'); -const inspectorRules = document.getElementById('inspector-rules'); -const inspectorRuleCount = document.getElementById('inspector-rule-count'); -const inspectorComputed = document.getElementById('inspector-computed'); -const inspectorQuickedit = document.getElementById('inspector-quickedit'); -const inspectorSend = document.getElementById('inspector-send'); -const inspectorSendBtn = document.getElementById('inspector-send-btn'); - -// Pick button -inspectorPickBtn.addEventListener('click', () => { - if (inspectorPickerActive) { - inspectorPickerActive = false; - inspectorPickBtn.classList.remove('active'); - chrome.runtime.sendMessage({ type: 'stopInspector' }); - } else { - inspectorPickerActive = true; - inspectorPickBtn.classList.add('active'); - inspectorShowLoading(false); // don't show loading yet, just activate - chrome.runtime.sendMessage({ type: 'startInspector' }, (result) => { - if (result?.error) { - inspectorPickerActive = false; - inspectorPickBtn.classList.remove('active'); - inspectorShowError(result.error); - } - }); - } -}); - -function inspectorShowEmpty() { - inspectorEmpty.style.display = ''; - inspectorLoading.style.display = 'none'; - inspectorError.style.display = 'none'; - inspectorPanels.style.display = 'none'; - inspectorSend.style.display = 'none'; -} - -function inspectorShowLoading(show) { - if (show) { - inspectorEmpty.style.display = 'none'; - inspectorLoading.style.display = ''; - inspectorError.style.display = 'none'; - inspectorPanels.style.display = 'none'; - } else { - inspectorLoading.style.display = 'none'; - } -} - -function inspectorShowError(message) { - inspectorEmpty.style.display = 'none'; - inspectorLoading.style.display = 'none'; - inspectorError.style.display = ''; - inspectorError.textContent = message; - inspectorPanels.style.display = 'none'; -} - -function inspectorShowData(data) { - inspectorData = data; - inspectorModifications = []; - inspectorEmpty.style.display = 'none'; - inspectorLoading.style.display = 'none'; - inspectorError.style.display = 'none'; - inspectorPanels.style.display = ''; - inspectorSend.style.display = ''; - - // Update toolbar - const tag = data.tagName || '?'; - const cls = data.classes && data.classes.length > 0 ? '.' + data.classes.join('.') : ''; - const idStr = data.id ? '#' + data.id : ''; - inspectorSelected.textContent = `<${tag}>${idStr}${cls}`; - inspectorSelected.title = data.selector; - - // Mode badge - if (data.mode === 'basic') { - inspectorModeBadge.textContent = 'Basic mode'; - inspectorModeBadge.style.display = ''; - inspectorModeBadge.className = 'inspector-mode-badge basic'; - } else if (data.mode === 'cdp') { - inspectorModeBadge.textContent = 'CDP'; - inspectorModeBadge.style.display = ''; - inspectorModeBadge.className = 'inspector-mode-badge cdp'; - } else { - inspectorModeBadge.style.display = 'none'; - } - - // Render sections - renderBoxModel(data); - renderMatchedRules(data); - renderComputedStyles(data); - renderQuickEdit(data); - updateSendButton(); -} - -// ─── Box Model Rendering ──────────────────────────────────────── - -function renderBoxModel(data) { - const box = data.basicData?.boxModel || data.boxModel; - if (!box) { inspectorBoxmodel.innerHTML = 'No box model data'; return; } - - const m = box.margin || {}; - const b = box.border || {}; - const p = box.padding || {}; - const c = box.content || {}; - - inspectorBoxmodel.innerHTML = ` -
- margin - ${fmtBoxVal(m.top)} - ${fmtBoxVal(m.right)} - ${fmtBoxVal(m.bottom)} - ${fmtBoxVal(m.left)} -
- border - ${fmtBoxVal(b.top)} - ${fmtBoxVal(b.right)} - ${fmtBoxVal(b.bottom)} - ${fmtBoxVal(b.left)} -
- padding - ${fmtBoxVal(p.top)} - ${fmtBoxVal(p.right)} - ${fmtBoxVal(p.bottom)} - ${fmtBoxVal(p.left)} -
- ${Math.round(c.width || 0)} x ${Math.round(c.height || 0)} -
-
-
-
- `; -} - -function fmtBoxVal(v) { - if (v === undefined || v === null) return '-'; - const n = typeof v === 'number' ? v : parseFloat(v); - if (isNaN(n) || n === 0) return '0'; - return Math.round(n * 10) / 10; -} - -// ─── Matched Rules Rendering ──────────────────────────────────── - -function renderMatchedRules(data) { - const rules = data.matchedRules || data.basicData?.matchedRules || []; - inspectorRuleCount.textContent = rules.length > 0 ? `(${rules.length})` : ''; - - if (rules.length === 0) { - inspectorRules.innerHTML = '
No matched rules
'; - return; - } - - // Separate UA rules from author rules - const authorRules = []; - const uaRules = []; - for (const rule of rules) { - if (rule.origin === 'user-agent' || rule.isUA) { - uaRules.push(rule); - } else { - authorRules.push(rule); - } - } - - let html = ''; - - // Author rules (expanded) - for (const rule of authorRules) { - html += renderRule(rule, false); - } - - // UA rules (collapsed by default) - if (uaRules.length > 0) { - html += ` -
- -
'; - } - - inspectorRules.innerHTML = html; - - // Bind UA toggle - const uaToggle = inspectorRules.querySelector('.inspector-ua-toggle'); - if (uaToggle) { - uaToggle.addEventListener('click', () => { - const body = inspectorRules.querySelector('.inspector-ua-body'); - const isCollapsed = uaToggle.classList.contains('collapsed'); - uaToggle.classList.toggle('collapsed', !isCollapsed); - uaToggle.setAttribute('aria-expanded', isCollapsed); - uaToggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '▼' : '▶'; - body.classList.toggle('collapsed', !isCollapsed); - }); - } -} - -function renderRule(rule, isUA) { - const selectorText = escapeHtml(rule.selector || ''); - const truncatedSelector = selectorText.length > 35 ? selectorText.slice(0, 35) + '...' : selectorText; - const source = rule.source || ''; - const sourceDisplay = source.includes('/') ? source.split('/').pop() : source; - const specificity = rule.specificity || ''; - - let propsHtml = ''; - const props = rule.properties || []; - for (const prop of props) { - const overridden = prop.overridden ? ' overridden' : ''; - const nameHtml = escapeHtml(prop.name); - const valText = escapeHtml(prop.value || ''); - const truncatedVal = valText.length > 30 ? valText.slice(0, 30) + '...' : valText; - const priority = prop.priority === 'important' ? ' !important' : ''; - propsHtml += `
${nameHtml}: ${truncatedVal}${priority};
`; - } - - return ` -
-
- ${truncatedSelector} - ${specificity ? `${escapeHtml(specificity)}` : ''} -
-
${propsHtml}
- ${sourceDisplay ? `
${escapeHtml(sourceDisplay)}
` : ''} -
- `; -} - -// ─── Computed Styles Rendering ────────────────────────────────── - -function renderComputedStyles(data) { - const styles = data.computedStyles || data.basicData?.computedStyles || {}; - const keys = Object.keys(styles); - - if (keys.length === 0) { - inspectorComputed.innerHTML = '
No computed styles
'; - return; - } - - let html = ''; - for (const key of keys) { - const val = styles[key]; - if (!val || val === 'none' || val === 'normal' || val === 'auto' || val === '0px' || val === 'rgba(0, 0, 0, 0)') continue; - html += `
${escapeHtml(key)}: ${escapeHtml(val)}
`; - } - - if (!html) { - html = '
All values are defaults
'; - } - - inspectorComputed.innerHTML = html; -} - -// ─── Quick Edit ───────────────────────────────────────────────── - -function renderQuickEdit(data) { - const selector = data.selector; - if (!selector) { inspectorQuickedit.innerHTML = ''; return; } - - // Show common editable properties with current values - const editableProps = ['color', 'background-color', 'font-size', 'padding', 'margin', 'border', 'display', 'opacity']; - const computed = data.computedStyles || data.basicData?.computedStyles || {}; - - let html = '
'; - for (const prop of editableProps) { - const val = computed[prop] || ''; - html += ` -
- ${escapeHtml(prop)}: - ${escapeHtml(val || '(none)')} -
- `; - } - html += '
'; - inspectorQuickedit.innerHTML = html; - - // Bind click-to-edit - inspectorQuickedit.querySelectorAll('.inspector-quickedit-value').forEach(el => { - el.addEventListener('click', () => startQuickEdit(el)); - el.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); startQuickEdit(el); } - }); - }); -} - -function startQuickEdit(valueEl) { - if (valueEl.querySelector('input')) return; // already editing - - const currentVal = valueEl.textContent === '(none)' ? '' : valueEl.textContent; - const prop = valueEl.dataset.prop; - const selector = valueEl.dataset.selector; - - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'inspector-quickedit-input'; - input.value = currentVal; - valueEl.textContent = ''; - valueEl.appendChild(input); - input.focus(); - input.select(); - - function commit() { - const newVal = input.value.trim(); - valueEl.textContent = newVal || '(none)'; - if (newVal && newVal !== currentVal) { - chrome.runtime.sendMessage({ - type: 'applyStyle', - selector, - property: prop, - value: newVal, - }); - inspectorModifications.push({ property: prop, value: newVal, selector }); - updateSendButton(); - } - } - - function cancel() { - valueEl.textContent = currentVal || '(none)'; - } - - input.addEventListener('blur', commit); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { e.preventDefault(); input.blur(); } - if (e.key === 'Escape') { e.preventDefault(); input.removeEventListener('blur', commit); cancel(); } - }); -} - -// ─── Send to Agent ────────────────────────────────────────────── - -function updateSendButton() { - if (inspectorModifications.length > 0) { - inspectorSendBtn.textContent = 'Send to Code'; - inspectorSendBtn.title = `${inspectorModifications.length} modification(s) to send`; - } else { - inspectorSendBtn.textContent = 'Send to Agent'; - inspectorSendBtn.title = 'Send full inspector data'; - } -} - -inspectorSendBtn.addEventListener('click', async () => { - if (!inspectorData) return; - - let message; - if (inspectorModifications.length > 0) { - // Format modification diff - const diffs = inspectorModifications.map(m => - ` ${m.property}: ${m.value} (selector: ${m.selector})` - ).join('\n'); - message = `CSS Inspector modifications:\n\nSelector: ${inspectorData.selector}\n\nChanges:\n${diffs}`; - - // Include source file info if available - const rules = inspectorData.matchedRules || inspectorData.basicData?.matchedRules || []; - const sources = rules.filter(r => r.source && r.source !== 'inline').map(r => r.source); - if (sources.length > 0) { - message += `\n\nSource files:\n${[...new Set(sources)].map(s => ` ${s}`).join('\n')}`; - } - } else { - // Send full inspector data - message = `CSS Inspector data for: ${inspectorData.selector}\n\n${JSON.stringify(inspectorData, null, 2)}`; - } - - // Inject into the running claude PTY so the user can ask claude to act - // on the inspector data. Replaces the old `sidebar-command` route which - // spawned a one-shot claude -p (sidebar-agent.ts is gone). - // - // Pre-scan via /pty-inject-scan before injection (D6, closes #1370). - // gstackScanForPTYInject is async; gstackInjectToTerminal stays sync. - const verdict = await window.gstackScanForPTYInject?.(message + '\n', 'inspector-send'); - if (verdict?.verdict === 'BLOCK') { - console.warn('[gstack sidebar] Inspector send BLOCKED by /pty-inject-scan:', verdict.reasons); - return; - } - if (verdict?.verdict === 'WARN') { - const confirmed = window.confirm( - `Inspector send flagged as suspicious (${(verdict.reasons || []).join(', ')}). Inject anyway?`, - ); - if (!confirmed) return; - } - const ok = window.gstackInjectToTerminal?.(message + '\n'); - if (!ok) { - console.warn('[gstack sidebar] Inspector send needs an active Terminal session.'); - } -}); - -// ─── Quick Action Helpers (toolbar buttons) ────────────────────── - -/** - * "Cleanup" injects a prompt into the running claude PTY. claude takes the - * prompt, snapshots the page, hides ads/banners/popups, leaves article - * content. The user watches it happen in the Terminal pane. - * - * Replaced the old chat-queue path (sidebar-agent.ts spawning a one-shot - * claude -p) — we have a live REPL now, so route through that instead. - */ -async function runCleanup(...buttons) { - buttons.forEach(b => b?.classList.add('loading')); - const cleanupPrompt = [ - 'Clean up the active browser page for reading. Run:', - '$B cleanup --all', - 'then $B snapshot -i, identify any remaining ads, cookie/consent banners,', - 'newsletter popups, login walls, video autoplay, sidebar widgets, share', - 'buttons, floating chat widgets, and hide each via $B eval. Keep the site', - 'header/masthead, headline, article body, images, byline, and date. Also', - 'unlock scrolling if the page is scroll-locked.', - ].join('\n'); - // Pre-scan via /pty-inject-scan before injection (D6, closes #1370). - // The cleanup prompt is a STATIC template (no page-derived content), so - // it will always PASS, but we still route it through the scan path so - // the invariant test in test/extension-pty-inject-invariant.test.ts - // confirms every call site goes through gstackScanForPTYInject first. - const verdict = await window.gstackScanForPTYInject?.(cleanupPrompt + '\n', 'cleanup-button'); - if (verdict?.verdict === 'BLOCK') { - console.warn('[gstack sidebar] Cleanup BLOCKED by /pty-inject-scan:', verdict.reasons); - setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 200); - return; - } - if (verdict?.verdict === 'WARN') { - const confirmed = window.confirm( - `Cleanup flagged as suspicious (${(verdict.reasons || []).join(', ')}). Inject anyway?`, - ); - if (!confirmed) { - setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 200); - return; - } - } - const sent = window.gstackInjectToTerminal?.(cleanupPrompt + '\n'); - if (!sent) { - console.warn('[gstack sidebar] Cleanup needs an active Terminal session.'); - } - setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 1200); -} - -async function runScreenshot(...buttons) { - if (!serverUrl || !serverToken) return; - buttons.forEach(b => b?.classList.add('loading')); - try { - const resp = await fetch(`${serverUrl}/command`, { - method: 'POST', - headers: { ...authHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: 'screenshot', args: [] }), - signal: AbortSignal.timeout(15000), - }); - const text = await resp.text(); - if (!resp.ok) { - console.warn('[gstack sidebar] Screenshot failed:', text); - } else { - console.log('[gstack sidebar] Screenshot:', text); - } - } catch (err) { - console.error('[gstack sidebar] Screenshot error:', err.message); - } finally { - buttons.forEach(b => b?.classList.remove('loading')); - } -} - -// ─── Wire up all cleanup/screenshot buttons (inspector + chat toolbar) ── - -const inspectorCleanupBtn = document.getElementById('inspector-cleanup-btn'); -const inspectorScreenshotBtn = document.getElementById('inspector-screenshot-btn'); -const chatCleanupBtn = document.getElementById('chat-cleanup-btn'); -const chatScreenshotBtn = document.getElementById('chat-screenshot-btn'); - -if (inspectorCleanupBtn) inspectorCleanupBtn.addEventListener('click', () => runCleanup(inspectorCleanupBtn, chatCleanupBtn)); -if (inspectorScreenshotBtn) inspectorScreenshotBtn.addEventListener('click', () => runScreenshot(inspectorScreenshotBtn, chatScreenshotBtn)); -if (chatCleanupBtn) chatCleanupBtn.addEventListener('click', () => runCleanup(chatCleanupBtn, inspectorCleanupBtn)); -if (chatScreenshotBtn) chatScreenshotBtn.addEventListener('click', () => runScreenshot(chatScreenshotBtn, inspectorScreenshotBtn)); - -// ─── Section Toggles ──────────────────────────────────────────── - -document.querySelectorAll('.inspector-section-toggle').forEach(toggle => { - toggle.addEventListener('click', () => { - const section = toggle.dataset.section; - const body = document.getElementById(`inspector-${section}`); - const isCollapsed = toggle.classList.contains('collapsed'); - - toggle.classList.toggle('collapsed', !isCollapsed); - toggle.setAttribute('aria-expanded', isCollapsed); - toggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '▼' : '▶'; - body.classList.toggle('collapsed', !isCollapsed); - }); -}); - -// ─── Inspector SSE ────────────────────────────────────────────── - -async function connectInspectorSSE() { - if (!serverUrl || !serverToken) return; - if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; } - - // Same session-cookie pattern as connectSSE. ?token= is gone (see N1 - // in the v1.6.0.0 security wave plan). - await ensureSseSessionCookie(); - const url = `${serverUrl}/inspector/events?_=${Date.now()}`; - - try { - inspectorSSE = new EventSource(url, { withCredentials: true }); - - inspectorSSE.addEventListener('inspectResult', (e) => { - try { - const data = JSON.parse(e.data); - inspectorShowData(data); - } catch (err) { - console.error('[gstack sidebar] Failed to parse inspectResult:', err.message); - } - }); - - inspectorSSE.addEventListener('error', () => { - // SSE connection failed — inspector works without it (basic mode) - if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; } - }); - } catch (err) { - console.debug('[gstack sidebar] Inspector SSE not available:', err.message); - } -} - -// ─── Server Discovery ─────────────────────────────────────────── - -function setActionButtonsEnabled(enabled) { - const btns = document.querySelectorAll('.quick-action-btn, .inspector-action-btn'); - btns.forEach(btn => { - btn.disabled = !enabled; - btn.classList.toggle('disabled', !enabled); - }); -} - -function updateConnection(url, token) { - const wasConnected = !!serverUrl; - serverUrl = url; - serverToken = token || null; - // Expose for sidepanel-terminal.js (PTY surface). The terminal pane needs - // the bootstrap token to POST /pty-session and the port to derive the WS - // URL. We never expose the PTY token — it lives in an HttpOnly cookie. - if (url) { - try { window.gstackServerPort = parseInt(new URL(url).port, 10); } catch {} - window.gstackAuthToken = token || null; - } else { - window.gstackServerPort = null; - window.gstackAuthToken = null; - } - if (url) { - document.getElementById('footer-dot').className = 'dot connected'; - const port = new URL(url).port; - document.getElementById('footer-port').textContent = `:${port}`; - setConnState('connected'); - setActionButtonsEnabled(true); - // Tell the active tab's content script the sidebar is open — this hides - // the welcome page arrow hint. Only fires on actual sidebar connection. - chrome.runtime.sendMessage({ type: 'sidebarOpened' }).catch(() => {}); - connectSSE(); - connectInspectorSSE(); - startMemPolling(); - } else { - document.getElementById('footer-dot').className = 'dot'; - document.getElementById('footer-port').textContent = ''; - const memEl = document.getElementById('footer-mem'); - if (memEl) { - memEl.textContent = ''; - memEl.classList.remove('warn', 'bad'); - } - stopMemPolling(); - setActionButtonsEnabled(false); - if (wasConnected) startReconnect(); - } -} - -// ─── Port Configuration ───────────────────────────────────────── - -const portLabel = document.getElementById('footer-port'); -const portInput = document.getElementById('port-input'); - -portLabel.addEventListener('click', () => { - portLabel.style.display = 'none'; - portInput.style.display = ''; - chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => { - portInput.value = resp?.port || ''; - portInput.focus(); - portInput.select(); - }); -}); - -function savePort() { - const port = parseInt(portInput.value, 10); - if (port > 0 && port < 65536) { - chrome.runtime.sendMessage({ type: 'setPort', port }); - } - portInput.style.display = 'none'; - portLabel.style.display = ''; -} -portInput.addEventListener('blur', savePort); -portInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') savePort(); - if (e.key === 'Escape') { portInput.style.display = 'none'; portLabel.style.display = ''; } -}); - -// ─── Reconnect / Copy Buttons ──────────────────────────────────── - -document.getElementById('conn-reconnect').addEventListener('click', () => { - reconnectAttempts = 0; - startReconnect(); -}); - -document.getElementById('conn-copy').addEventListener('click', () => { - navigator.clipboard.writeText('/open-gstack-browser').then(() => { - const btn = document.getElementById('conn-copy'); - btn.textContent = 'copied!'; - setTimeout(() => { btn.textContent = '/open-gstack-browser'; }, 2000); - }); -}); - -// Try to connect immediately, retry every 2s until connected. -// Show exactly what's happening at each step so the user is never -// staring at a blank "Connecting..." with no info. -let connectAttempts = 0; -function setLoadingStatus(msg, debug) { - // The status line lives inside the Terminal bootstrap card now — - // sidepanel-terminal.js owns it. We only update the debug pre block, - // and trust the terminal pane to surface the human-readable status. - const dbg = document.getElementById('loading-debug'); - if (dbg && debug !== undefined) dbg.textContent = debug; -} - -async function tryConnect() { - connectAttempts++; - setLoadingStatus( - `Looking for browse server... (attempt ${connectAttempts})`, - `Asking background.js for server port...` - ); - - // Step 1: Ask background for the port - const resp = await new Promise(resolve => { - chrome.runtime.sendMessage({ type: 'getPort' }, (r) => { - if (chrome.runtime.lastError) { - resolve({ error: chrome.runtime.lastError.message }); - } else { - resolve(r || {}); - } - }); - }); - - if (resp.error) { - setLoadingStatus( - `Extension error (attempt ${connectAttempts})`, - `chrome.runtime.sendMessage failed:\n${resp.error}` - ); - setTimeout(tryConnect, 2000); - return; - } - - const port = resp.port || 34567; - - // Step 2: If background says connected + has token, use that - if (resp.port && resp.connected && resp.token) { - setLoadingStatus( - `Server found on port ${port}, connecting...`, - `token: yes\nStarting SSE + chat polling...` - ); - updateConnection(`http://127.0.0.1:${port}`, resp.token); - return; - } - - // Step 3: Background not connected yet. Try hitting /health directly. - // This bypasses the background.js health poll timing gap. - setLoadingStatus( - `Checking server directly... (attempt ${connectAttempts})`, - `port: ${port}\nbackground connected: ${resp.connected || false}\nTrying GET http://127.0.0.1:${port}/health ...` - ); - - try { - const healthResp = await fetch(`http://127.0.0.1:${port}/health`, { - signal: AbortSignal.timeout(2000) - }); - if (healthResp.ok) { - const data = await healthResp.json(); - if (data.status === 'healthy' && data.token) { - setLoadingStatus( - `Server healthy on port ${port}, connecting...`, - `token: yes (from /health)\nStarting SSE + activity feed...` - ); - updateConnection(`http://127.0.0.1:${port}`, data.token); - // The SEC shield used to drive off /health.security via the chat - // path's classifier; with the chat path ripped, the indicator is - // not driven yet. Leaving the shield element hidden by default. - return; - } - setLoadingStatus( - `Server responded but not healthy (attempt ${connectAttempts})`, - `status: ${data.status}\ntoken: ${data.token ? 'yes' : 'no'}` - ); - } else { - setLoadingStatus( - `Server returned ${healthResp.status} (attempt ${connectAttempts})`, - `GET /health → ${healthResp.status} ${healthResp.statusText}` - ); - } - } catch (e) { - setLoadingStatus( - `Server not reachable on port ${port} (attempt ${connectAttempts})`, - `GET /health failed: ${e.message}\n\nThe browse server may still be starting.\nRun /open-gstack-browser in Claude Code.` - ); - } - - setTimeout(tryConnect, 2000); -} -tryConnect(); - -// ─── Message Listener ─────────────────────────────────────────── - -chrome.runtime.onMessage.addListener((msg) => { - if (msg.type === 'health') { - if (msg.data) { - const url = `http://127.0.0.1:${msg.data.port || 34567}`; - // Request token via targeted sendResponse (not broadcast) to limit exposure - chrome.runtime.sendMessage({ type: 'getToken' }, (resp) => { - updateConnection(url, resp?.token || null); - }); - } else { - updateConnection(null); - } - } - if (msg.type === 'refs') { - if (document.querySelector('.tab[data-tab="refs"].active')) { - fetchRefs(); - } - } - if (msg.type === 'inspectResult') { - inspectorPickerActive = false; - inspectorPickBtn.classList.remove('active'); - if (msg.data) { - inspectorShowData(msg.data); - } else { - inspectorShowError('Element not found, try picking again'); - } - } - if (msg.type === 'pickerCancelled') { - inspectorPickerActive = false; - inspectorPickBtn.classList.remove('active'); - } - // browserTabState: full snapshot of all open tabs + the active one, - // pushed by background.js on chrome.tabs events. We forward it as a - // custom event so sidepanel-terminal.js can relay to terminal-agent.ts. - // Result: claude's /tabs.json + active-tab.json stay live. - if (msg.type === 'browserTabState') { - document.dispatchEvent(new CustomEvent('gstack:tab-state', { - detail: { active: msg.active, tabs: msg.tabs, reason: msg.reason }, - })); - } -}); - -// ─── v1.44 pagehide: explicit PTY dispose on sidebar close ────────── -// -// Codex T3 of the eng review: WS close codes alone can't distinguish -// "intentional close" (sidebar closed, browser quit, extension reload) -// from "transient blip" (wifi hiccup) reliably — Chrome routes the -// former through code 1001 (going-away) and the latter through 1006 -// (abnormal), but neither is a load-bearing contract across browsers -// and extension lifecycles. -// -// pagehide fires reliably for tab close, panel close, extension reload, -// and navigation-away. We use it to fire-and-forget a /pty-dispose POST -// so the server can synchronously dispose the PtySession instead of -// waiting for the 60s detach window (Commit 3) to time out. Zombie -// claude processes lingering for 60s on every browser quit was the -// codex-flagged failure mode. -// -// sendBeacon is the only fetch primitive that survives a closing page — -// it doesn't accept custom headers, which is why the server's -// /pty-dispose route accepts the auth token in the BODY (see -// server-pty-lease-routes.test.ts test 4). -window.addEventListener('pagehide', () => { - const sessionId = window.gstackPtySession; - const authToken = window.gstackAuthToken; - const port = window.gstackServerPort; - if (!sessionId || !authToken || !port) return; - try { - const blob = new Blob([JSON.stringify({ sessionId, authToken })], { - type: 'application/json', - }); - navigator.sendBeacon(`http://127.0.0.1:${port}/pty-dispose`, blob); - } catch { - // Best-effort — the 60s detach timer will catch any session we miss. - } -}); diff --git a/package.json b/package.json index 1fc3ff76b8..111e586fc8 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "scripts": { "build": "bash scripts/build.sh", "build:runtime": "bash scripts/build.sh --runtime-only", - "vendor:xterm": "mkdir -p extension/lib && cp node_modules/xterm/lib/xterm.js extension/lib/xterm.js && cp node_modules/xterm/css/xterm.css extension/lib/xterm.css && cp node_modules/xterm-addon-fit/lib/xterm-addon-fit.js extension/lib/xterm-addon-fit.js", "dev:make-pdf": "bun run make-pdf/src/cli.ts", "dev:design": "bun run design/src/cli.ts", "build:diagram-render": "cd lib/diagram-render && bun install && bun run scripts/build.ts", @@ -77,9 +76,7 @@ "marked": "^18.0.2", "playwright": "npm:playwright-core@^1.58.2", "sharp": "^0.34.5", - "socks": "^2.8.8", - "xterm": "5", - "xterm-addon-fit": "^0.8.0" + "socks": "^2.8.8" }, "engines": { "bun": ">=1.0.0", diff --git a/runtime/install.js b/runtime/install.js index 9046a2a6f0..6002ee0275 100644 --- a/runtime/install.js +++ b/runtime/install.js @@ -272,7 +272,6 @@ export const DEFAULT_RUNTIME_BUNDLE = Object.freeze([ // Keep its small, audited dependency closure explicit instead of copying all // node_modules or introducing a cloud browser. entry("browse/src"), - entry("extension"), entry("node_modules/playwright"), entry(managedBunRelativePath(), "managed-bun", true), entry(".gstack-runtime-browsers", "browser"), @@ -317,7 +316,7 @@ export const DEFAULT_CAPABILITY_LAUNCHERS = Object.freeze({ const CAPABILITY_PATH_PREFIXES = Object.freeze({ browser: Object.freeze([ - "browse/", "extension/", ".gstack-runtime-browsers", "node_modules/playwright", "node_modules/diff", "node_modules/socks", + "browse/", ".gstack-runtime-browsers", "node_modules/playwright", "node_modules/diff", "node_modules/socks", "node_modules/smart-buffer", "node_modules/ip-address", "node_modules/sharp", "node_modules/@img/", "node_modules/@ngrok/", "node_modules/detect-libc", "node_modules/semver", ]), diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 7d3d0dab35..74c5ce3f70 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -4,7 +4,6 @@ # Creates a self-contained .app with: # - Compiled browse binary # - Playwright's bundled Chromium -# - Chrome extension (sidebar) # - Info.plist with bundle ID # # Output: dist/GStack Browser.app and dist/GStack-Browser.dmg @@ -63,11 +62,6 @@ chmod +x "$APP_DIR/Contents/MacOS/gstack-browser" cp "$BUILD_DIR/browse-app" "$APP_DIR/Contents/Resources/browse" chmod +x "$APP_DIR/Contents/Resources/browse" -# Extension -cp -r "$ROOT/extension" "$APP_DIR/Contents/Resources/extension" -# Remove .auth.json if present (auth now via /health endpoint) -rm -f "$APP_DIR/Contents/Resources/extension/.auth.json" - # Server source (needed for `bun run server.ts` subprocess) # The launcher sets BROWSE_SERVER_SCRIPT to point at this. # Copy the full src/ directory since server.ts imports other modules. @@ -163,7 +157,6 @@ echo "" echo " $APP_NAME.app: $APP_SIZE" echo " Contents/MacOS/gstack-browser (launcher)" echo " Contents/Resources/browse ($(du -sh "$APP_DIR/Contents/Resources/browse" | cut -f1))" -echo " Contents/Resources/extension/ ($(du -sh "$APP_DIR/Contents/Resources/extension" | cut -f1))" echo " Contents/Resources/chromium/ ($(du -sh "$APP_DIR/Contents/Resources/chromium" | cut -f1))" # ─── Step 6: DMG (optional) ───────────────────────────────────── diff --git a/scripts/build.sh b/scripts/build.sh index 0678a052c0..ec9982fb8b 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -32,7 +32,6 @@ case "$(uname -s)" in ;; esac -"$BUN_CMD" run vendor:xterm "$BUN_CMD" build --compile browse/src/cli.ts --outfile browse/dist/browse "$BUN_CMD" build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse "$BUN_CMD" build --compile design/src/cli.ts --outfile design/dist/design From d596232248bb74cff661c4223e714cbc0cc4b6dd Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 14:18:18 -0700 Subject: [PATCH 2/8] refactor: remove in-browser PTY terminal Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/src/cli.ts | 40 +- browse/src/pty-session-cookie.ts | 122 -- browse/src/pty-session-lease.ts | 137 --- browse/src/server.ts | 449 +------- browse/src/sidebar-utils.ts | 21 - browse/src/terminal-agent-control.ts | 143 --- browse/src/terminal-agent.ts | 1011 ----------------- browse/test/dual-listener.test.ts | 4 +- browse/test/pty-session-lease.test.ts | 98 -- browse/test/security-sidepanel-dom.test.ts | 265 ----- browse/test/server-auth.test.ts | 4 +- .../server-embedder-terminal-port.test.ts | 232 ---- browse/test/server-pty-lease-routes.test.ts | 94 -- browse/test/sidebar-integration.test.ts | 122 -- browse/test/sidebar-security.test.ts | 134 --- browse/test/sidebar-tabs.test.ts | 270 ----- browse/test/sidebar-unit.test.ts | 96 -- browse/test/sidebar-ux.test.ts | 240 ---- .../sidepanel-patient-autoconnect.test.ts | 70 -- browse/test/sidepanel-reattach.test.ts | 93 -- browse/test/sidepanel-restart-dispose.test.ts | 106 -- .../terminal-agent-detach-reattach.test.ts | 127 --- .../test/terminal-agent-integration.test.ts | 273 ----- .../terminal-agent-internal-handler.test.ts | 51 - browse/test/terminal-agent-keepalive.test.ts | 88 -- .../test/terminal-agent-pid-identity.test.ts | 161 --- ...terminal-agent-ring-buffer-runtime.test.ts | 155 --- .../terminal-agent-session-routing.test.ts | 96 -- browse/test/terminal-agent-watchdog.test.ts | 91 -- browse/test/terminal-agent.test.ts | 258 ----- test/extension-pty-inject-invariant.test.ts | 141 --- test/skill-e2e-sidebar.test.ts | 471 -------- 32 files changed, 11 insertions(+), 5652 deletions(-) delete mode 100644 browse/src/pty-session-cookie.ts delete mode 100644 browse/src/pty-session-lease.ts delete mode 100644 browse/src/sidebar-utils.ts delete mode 100644 browse/src/terminal-agent-control.ts delete mode 100644 browse/src/terminal-agent.ts delete mode 100644 browse/test/pty-session-lease.test.ts delete mode 100644 browse/test/security-sidepanel-dom.test.ts delete mode 100644 browse/test/server-embedder-terminal-port.test.ts delete mode 100644 browse/test/server-pty-lease-routes.test.ts delete mode 100644 browse/test/sidebar-integration.test.ts delete mode 100644 browse/test/sidebar-security.test.ts delete mode 100644 browse/test/sidebar-tabs.test.ts delete mode 100644 browse/test/sidebar-unit.test.ts delete mode 100644 browse/test/sidebar-ux.test.ts delete mode 100644 browse/test/sidepanel-patient-autoconnect.test.ts delete mode 100644 browse/test/sidepanel-reattach.test.ts delete mode 100644 browse/test/sidepanel-restart-dispose.test.ts delete mode 100644 browse/test/terminal-agent-detach-reattach.test.ts delete mode 100644 browse/test/terminal-agent-integration.test.ts delete mode 100644 browse/test/terminal-agent-internal-handler.test.ts delete mode 100644 browse/test/terminal-agent-keepalive.test.ts delete mode 100644 browse/test/terminal-agent-pid-identity.test.ts delete mode 100644 browse/test/terminal-agent-ring-buffer-runtime.test.ts delete mode 100644 browse/test/terminal-agent-session-routing.test.ts delete mode 100644 browse/test/terminal-agent-watchdog.test.ts delete mode 100644 browse/test/terminal-agent.test.ts delete mode 100644 test/extension-pty-inject-invariant.test.ts delete mode 100644 test/skill-e2e-sidebar.test.ts diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 93e4f17da7..256cf6891e 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -17,7 +17,6 @@ import { writeSecureFile, mkdirSecure } from './file-permissions'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config'; import { redactProxyUrl } from './proxy-redact'; -import { spawnTerminalAgent } from './terminal-agent-control'; const config = resolveConfig(); const IS_WINDOWS = process.platform === 'win32'; @@ -1095,14 +1094,13 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: // Delete stale state file safeUnlinkQuiet(config.stateFile); - console.log('Launching headed Chromium with extension + terminal agent...'); + console.log('Launching headed Chromium...'); try { - // Start server in headed mode with extension auto-loaded - // Use a well-known port so the Chrome extension auto-connects + // Start server in headed mode. + // Use a well-known port so callers auto-connect. const serverEnv: Record = { BROWSE_HEADED: '1', BROWSE_PORT: '34567', - BROWSE_SIDEBAR_CHAT: '1', // Disable parent-process watchdog: the user controls the headed browser // window lifecycle. The CLI exits immediately after connect, so watching // it would kill the server ~15s later. Cleanup happens via browser @@ -1134,28 +1132,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: console.log('(If you still don\'t see it, check Mission Control / other Spaces.)'); } - // sidebar-agent.ts spawn was here. Ripped alongside the chat queue — - // the Terminal pane runs an interactive PTY now, no more one-shot - // claude -p subprocesses to multiplex. - - // Auto-start terminal agent (non-compiled bun process). Owns the PTY - // WebSocket for the sidebar Terminal pane. Routes through the shared - // spawnTerminalAgent helper so the CLI cold-start path and the - // server.ts watchdog respawn path share one implementation. The - // helper handles prior-PID cleanup, script lookup, and env wiring. - try { - const newPid = spawnTerminalAgent({ - stateFile: config.stateFile, - serverPort: newState.port, - cwd: config.projectDir, - }); - if (newPid) { - console.log(`[browse] Terminal agent started (PID: ${newPid})`); - } - } 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); @@ -1234,16 +1210,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: try { const respawned = await startServer(serverEnv); console.log(`[browse] Supervisor: server respawned (PID ${respawned.pid}, port ${respawned.port}).`); - // Re-spawn the terminal-agent too; same env wiring as the initial connect. - try { - spawnTerminalAgent({ - stateFile: config.stateFile, - serverPort: respawned.port, - cwd: config.projectDir, - }); - } catch (err: any) { - console.warn(`[browse] Supervisor: terminal-agent respawn failed: ${err?.message || err}`); - } } catch (err: any) { console.error(`[browse] Supervisor: server respawn failed: ${err?.message || err}`); // Let the next tick try again — the crash-loop guard already diff --git a/browse/src/pty-session-cookie.ts b/browse/src/pty-session-cookie.ts deleted file mode 100644 index 8871fe471b..0000000000 --- a/browse/src/pty-session-cookie.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Session cookie registry for the Terminal sidebar tab's PTY WebSocket. - * - * Why this exists: WebSocket clients in browsers cannot send Authorization - * headers on the upgrade request. The terminal-agent's /ws upgrade therefore - * authenticates via cookie. We never put the PTY token in /health (codex - * outside-voice finding #2: /health already leaks AUTH_TOKEN to any - * localhost caller in headed mode; reusing that path for shell access would - * widen an existing bug). Instead, the extension does an authenticated - * POST /pty-session with the bootstrap AUTH_TOKEN; the server mints a - * short-lived cookie scoped to this terminal session and pushes it to the - * agent via loopback. The browser then carries the cookie automatically on - * the WS upgrade. - * - * Design mirrors `sse-session-cookie.ts` deliberately. Same TTL, same - * scoped-token-must-not-be-valid-as-root invariant, same opportunistic - * pruning. Two registries instead of one because the cookie names are - * different (`gstack_sse` vs `gstack_pty`) and the token spaces must not - * overlap — an SSE-read cookie must never grant PTY access, and vice versa. - */ -import * as crypto from 'crypto'; - -interface Session { - createdAt: number; - expiresAt: number; -} - -const TTL_MS = 30 * 60 * 1000; // 30 minutes — matches SSE cookie -const MAX_SESSIONS = 10_000; -const sessions = new Map(); - -export const PTY_COOKIE_NAME = 'gstack_pty'; - -/** Mint a fresh PTY session token. */ -export function mintPtySessionToken(): { token: string; expiresAt: number } { - const token = crypto.randomBytes(32).toString('base64url'); - const now = Date.now(); - const expiresAt = now + TTL_MS; - sessions.set(token, { createdAt: now, expiresAt }); - pruneExpired(now); - return { token, expiresAt }; -} - -/** - * Validate a token. Returns true only if the token exists AND is not expired. - * Lazily removes expired entries; opportunistically prunes a few more on - * every call so the registry stays bounded under reconnect pressure. - */ -export function validatePtySessionToken(token: string | null | undefined): boolean { - if (!token) return false; - const s = sessions.get(token); - if (!s) { - pruneExpired(Date.now()); - return false; - } - if (Date.now() > s.expiresAt) { - sessions.delete(token); - pruneExpired(Date.now()); - return false; - } - return true; -} - -/** - * Drop a session token (called on WS close so a leaked cookie can't be - * replayed against a new PTY). - */ -export function revokePtySessionToken(token: string | null | undefined): void { - if (!token) return; - sessions.delete(token); -} - -/** Parse the PTY session token from a Cookie header. */ -export function extractPtyCookie(req: Request): string | null { - const cookieHeader = req.headers.get('cookie'); - if (!cookieHeader) return null; - for (const part of cookieHeader.split(';')) { - const [name, ...valueParts] = part.trim().split('='); - if (name === PTY_COOKIE_NAME) { - return valueParts.join('=') || null; - } - } - return null; -} - -/** - * Build the Set-Cookie header value for the PTY session cookie. - * - HttpOnly: not readable from JS (mitigates XSS exfiltration). - * - SameSite=Strict: not sent on cross-site requests (mitigates CSWSH). - * - Path=/: scope to whole origin so /ws and /pty-session both see it. - * - Max-Age matches the TTL. - * - * Secure is intentionally omitted: the daemon binds to 127.0.0.1 over plain - * HTTP; setting Secure would prevent the browser from ever sending it back. - */ -export function buildPtySetCookie(token: string): string { - const maxAge = Math.floor(TTL_MS / 1000); - return `${PTY_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`; -} - -/** Clear the PTY session cookie. */ -export function buildPtyClearCookie(): string { - return `${PTY_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`; -} - -function pruneExpired(now: number): void { - let checked = 0; - for (const [token, session] of sessions) { - if (checked++ >= 20) break; - if (session.expiresAt <= now) sessions.delete(token); - } - while (sessions.size > MAX_SESSIONS) { - const first = sessions.keys().next().value; - if (!first) break; - sessions.delete(first); - } -} - -// Test-only reset. -export function __resetPtySessions(): void { - sessions.clear(); -} diff --git a/browse/src/pty-session-lease.ts b/browse/src/pty-session-lease.ts deleted file mode 100644 index ec2797889c..0000000000 --- a/browse/src/pty-session-lease.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * PTY session lease registry (v1.44+). - * - * Separates two concerns that pre-v1.44 were conflated under one token: - * - * - **sessionId** — stable, non-secret identifier for a single PTY session. - * Safe to log, safe to include in URLs and server access logs, safe to - * keep in DevTools. Identifies "this terminal," not "you're allowed to - * use this terminal." - * - * - **attachToken** — secret, short-lived (30 s) bearer credential that - * grants the WS upgrade for ONE attach attempt against a session. Minted - * on every /pty-session and /pty-session/reattach call; revoked when - * the WS upgrade consumes it. Kept out of logs. - * - * - **lease** — server-side bookkeeping that maps sessionId → expiresAt. - * Re-attach within the lease window resumes the same PTY (and replays - * the ring buffer from terminal-agent). Lease expiry tears down the - * session. - * - * Codex outside-voice (T1 of the eng review) pushed for this separation: - * "the auth token IS the session id" collapsed identity into a secret, - * meaning re-attach URLs and logs carry the bearer credential. The lease - * model fixes that without changing the user experience. - * - * Mint cadence: - * - Initial /pty-session: mint sessionId + lease + attachToken (one round trip). - * - /pty-session/reattach: validate sessionId/lease, mint fresh attachToken. - * - /pty-restart: revoke old lease, mint fresh sessionId + lease + attachToken. - * - /pty-dispose: revoke lease (and the terminal-agent disposes the PTY). - * - * Lease TTL is env-overridable so v1.44 e2e tests can compress detach - * windows to 1 s instead of waiting 30 minutes per assertion. - */ -import * as crypto from 'crypto'; - -interface Lease { - createdAt: number; - expiresAt: number; -} - -const LEASE_TTL_MS = parseInt( - process.env.GSTACK_PTY_LEASE_TTL_MS || `${30 * 60 * 1000}`, - 10, -); // 30 minutes default; covers idle-but-engaged user sessions -const MAX_LEASES = 10_000; -const leases = new Map(); - -/** - * Mint a fresh sessionId + lease. Returns the non-secret sessionId and - * the expiry timestamp (caller surfaces both to the client). Never throws. - */ -export function mintLease(): { sessionId: string; expiresAt: number } { - const sessionId = crypto.randomBytes(32).toString('base64url'); - const now = Date.now(); - const expiresAt = now + LEASE_TTL_MS; - leases.set(sessionId, { createdAt: now, expiresAt }); - pruneExpired(now); - return { sessionId, expiresAt }; -} - -/** - * Check whether a lease is still valid (exists AND not expired). Returns - * the current expiresAt for valid leases; null otherwise. Lazily prunes - * stale entries. - */ -export function validateLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } { - if (!sessionId) return { ok: false }; - const lease = leases.get(sessionId); - if (!lease) { - pruneExpired(Date.now()); - return { ok: false }; - } - if (Date.now() > lease.expiresAt) { - leases.delete(sessionId); - pruneExpired(Date.now()); - return { ok: false }; - } - return { ok: true, expiresAt: lease.expiresAt }; -} - -/** - * Extend the lease's expiresAt to `now + LEASE_TTL_MS`. Caller should - * gate refresh on `expiresAt - now < REFRESH_THRESHOLD` (D10 lazy - * refresh: avoid refreshing on every keepalive when the lease is - * comfortably far from expiry). - * - * Returns `{ ok: true, expiresAt }` on success, `{ ok: false }` if the - * lease is unknown or already expired (the agent must close the WS and - * surface auth-invalid). Critical security invariant: never resurrect - * an expired lease — the 30-min TTL is what bounds blast radius for a - * leaked attach token whose lease should have been GC'd. - */ -export function refreshLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } { - if (!sessionId) return { ok: false }; - const lease = leases.get(sessionId); - if (!lease) return { ok: false }; - const now = Date.now(); - if (now > lease.expiresAt) { - leases.delete(sessionId); - return { ok: false }; - } - lease.expiresAt = now + LEASE_TTL_MS; - return { ok: true, expiresAt: lease.expiresAt }; -} - -/** - * Drop a lease. Called on explicit dispose (/pty-dispose, /pty-restart, - * WS close with code 4001) and on session timeout in terminal-agent. - */ -export function revokeLease(sessionId: string | null | undefined): void { - if (!sessionId) return; - leases.delete(sessionId); -} - -/** Returns the lease count — test + observability helper. */ -export function leaseCount(): number { - return leases.size; -} - -/** Test-only reset. */ -export function __resetLeases(): void { - leases.clear(); -} - -function pruneExpired(now: number): void { - let checked = 0; - for (const [sessionId, lease] of leases) { - if (checked++ >= 20) break; - if (lease.expiresAt <= now) leases.delete(sessionId); - } - while (leases.size > MAX_LEASES) { - const first = leases.keys().next().value; - if (!first) break; - leases.delete(first); - } -} diff --git a/browse/src/server.ts b/browse/src/server.ts index ca7ef19eb6..e8c8220ba6 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -18,7 +18,6 @@ import { handleReadCommand, hasOutArg } from './read-commands'; import { handleWriteCommand } from './write-commands'; import { handleMetaCommand } from './meta-commands'; import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes'; -import { sanitizeExtensionUrl } from './sidebar-utils'; import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, DOM_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands'; import { wrapUntrustedPageContent, datamarkContent, @@ -44,8 +43,6 @@ import { inspectElement, modifyStyle, resetModifications, getModificationHistory // Bun.spawn used instead of child_process.spawn (compiled bun binaries // fail posix_spawn on all executables including /bin/bash) import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling'; -import { readAgentRecord, killAgentByRecord, clearAgentRecord, agentRecordPath, spawnTerminalAgent } from './terminal-agent-control'; -import { isProcessAlive } from './error-handling'; import { sanitizeBody, stripLoneSurrogateEscapes } from './sanitize'; import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge'; import { parseProxyConfig, toUpstreamConfig, ProxyConfigError } from './proxy-config'; @@ -56,12 +53,6 @@ import { mintSseSessionToken, validateSseSessionToken, extractSseCookie, buildSseSetCookie, SSE_COOKIE_NAME, } from './sse-session-cookie'; -import { - mintPtySessionToken, buildPtySetCookie, revokePtySessionToken, -} from './pty-session-cookie'; -import { - mintLease, validateLease, refreshLease, revokeLease, -} from './pty-session-lease'; import * as fs from 'fs'; import * as net from 'net'; import * as path from 'path'; @@ -211,38 +202,6 @@ export interface ServerConfig { * dispatch; returning null falls through. */ beforeRoute?: (req: Request, surface: Surface, auth: TokenInfo | null) => Promise; - /** - * Whether gstack owns the lifecycle of the terminal-agent process and its - * discovery files (`/terminal-port`, `/terminal-internal-token`, - * `/terminal-agent-pid`). - * - * When true (default), shutdown() runs four side effects: - * 1. Identity-based kill via `killAgentByRecord(readAgentRecord(stateDir))` - * (v1.44+). Only signals the PID recorded by THIS daemon's agent. - * Replaced the historical `pkill -f terminal-agent\.ts` regex that - * matched sibling gstack sessions on the same host — see - * terminal-agent-control.ts for rationale. - * 2. `safeUnlinkQuiet(/terminal-port)` - * 3. `safeUnlinkQuiet(/terminal-internal-token)` - * 4. `safeUnlinkQuiet(/terminal-agent-pid)` (the v1.44 record) - * - * This is correct for gstack's CLI path, which spawns `terminal-agent.ts` as - * the producer of those files (see cli.ts:1037-1063). - * - * Embedders (gbrowser phoenix overlay, future hosts) that run their own PTY - * server and write those files themselves should pass `false`. When `false`, - * the embedder owns BOTH the agent process AND all three discovery files. - * Note that terminal-agent.ts's own SIGTERM cleanup removes `terminal-port` - * and `terminal-agent-pid` (the agent writes both at boot), so embedders - * that pre-launch their own agent must ensure their cleanup matches. - * - * Polarity note: this differs from `xvfb?` and `proxyBridge?`, which gate by - * the *presence* of a caller-owned handle (presence ⇒ don't close). This - * field gates by an explicit boolean because there is no handle object — - * the terminal-agent is started elsewhere (cli.ts), and shutdown's only - * reference is the PID record + the file paths. - */ - ownsTerminalAgent?: boolean; } /** @@ -253,7 +212,7 @@ export interface ServerHandle { fetchLocal: (req: Request, server: any) => Promise; fetchTunnel: (req: Request, server: any) => Promise; /** - * Drains buffers, kills terminal-agent, closes browser, clears intervals, + * Drains buffers, closes browser, clears intervals, * removes state files. Does NOT stop bound Bun.Server listeners — call * stopListeners() for that. CLI relies on process.exit() to drop sockets. */ @@ -302,7 +261,6 @@ export function resolveConfigFromEnv(): Omit([ '/connect', '/command', - '/sidebar-chat', ]); /** @@ -395,77 +353,6 @@ async function closeTunnel(): Promise { // in buildFetchHandler closes over cfg.authToken so every internal auth check // sees the same token the routes receive. -/** - * Terminal-agent discovery. The non-compiled bun process at - * `browse/src/terminal-agent.ts` writes its chosen port to - * `/terminal-port` and the loopback handshake token to - * `/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. v1.44+: also pushes the bound - * sessionId so the agent can route /internal/restart and (Commit 3) - * re-attach back to the same PtySession. Loopback POST authenticated - * with the internal token written by the agent at startup. If the agent - * isn't up yet, the extension just retries /pty-session. - */ -async function grantPtyToken(token: string, sessionId?: string): Promise { - 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(sessionId ? { token, sessionId } : { token }), - signal: AbortSignal.timeout(2000), - }); - return resp.ok; - } catch { return false; } -} - -/** - * Ask the terminal-agent to dispose the PtySession bound to `sessionId`. - * Scoped to one caller's session — sibling tabs/agents untouched. Used by - * /pty-restart and /pty-dispose. Returns true on agent ack. - */ -async function restartPtySession(sessionId: string): Promise { - const port = readTerminalPort(); - const internal = readTerminalInternalToken(); - if (!port || !internal) return false; - try { - const resp = await fetch(`http://127.0.0.1:${port}/internal/restart`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${internal}`, - }, - body: JSON.stringify({ sessionId }), - signal: AbortSignal.timeout(5000), - }); - 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'); @@ -1450,11 +1337,9 @@ if (import.meta.main) { /** * Build a request handler set for the browse daemon. Embedders (gbrowser * phoenix overlay) call this directly with their own cfg to compose overlay - * routes via cfg.beforeRoute, pass a pre-launched cfg.browserManager, and - * opt out of terminal-agent teardown via cfg.ownsTerminalAgent (default - * true, set to false when the embedder runs its own PTY server). The CLI - * path calls this through start() with env-derived defaults and explicit - * cfg.ownsTerminalAgent: true — externally-observable behavior is identical. + * routes via cfg.beforeRoute and pass a pre-launched cfg.browserManager. The + * CLI path calls this through start() with env-derived defaults — + * externally-observable behavior is identical. * * Auth state lives ENTIRELY inside the factory closure: cfg.authToken is the * single source of truth for the bearer secret, factory-scoped validateAuth @@ -1484,89 +1369,6 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { initRegistry(cfg.authToken); const { authToken, browserManager: cfgBrowserManager, startTime, beforeRoute, browsePort } = cfg; - // Strict opt-out: only explicit `false` flips the gate. Any other value - // (undefined, truthy non-bool from a JS caller bypassing TS, etc.) defaults - // to gstack-owns. Matches the "default-true preserves CLI bit-for-bit" - // premise even under malformed cfg. - const ownsTerminalAgent = cfg.ownsTerminalAgent === false ? false : true; - - // ─── Terminal-Agent Watchdog (v1.44+) ───────────────────────────── - // - // The terminal-agent process can die independently of the server: SIGKILL - // from the OS OOM killer, an uncaught exception under load, an external - // `pkill` from a sibling debugging session. Pre-v1.44 the sidebar would - // see the broken connection and stay broken until the user reloaded. - // Now: 60s ticker checks the recorded agent PID, respawns via the shared - // spawnTerminalAgent helper if dead. - // - // Identity-based — uses readAgentRecord + isProcessAlive, NOT a process - // name probe. Critical: prevents respawning around a slow-but-alive agent - // (which would create split-brain — two agents writing the port file, - // tokens diverging between them, mystery PTY upgrade failures). - // - // Crash-loop guard: 3 respawn attempts inside 60s → stop trying and emit - // a one-line error. Manual `forceRestart` from the sidebar clears the - // history (the user is the explicit signal to retry). - // - // Only active when ownsTerminalAgent === true. Embedders that pre-launch - // their own PTY server (gbrowser phoenix overlay) must not be auto-respawned - // by us — their lifecycle is their concern. - let agentWatchdogInterval: ReturnType | null = null; - const respawnHistory: number[] = []; - const AGENT_WATCHDOG_TICK_MS = parseInt( - process.env.GSTACK_AGENT_WATCHDOG_TICK_MS || '60000', - 10, - ); - const RESPAWN_GUARD_WINDOW_MS = 60_000; - const RESPAWN_GUARD_MAX = 3; - let agentRespawnGuardTripped = false; - - if (ownsTerminalAgent) { - agentWatchdogInterval = setInterval(() => { - if (isShuttingDown) return; - if (agentRespawnGuardTripped) return; - const stateDir = path.dirname(cfg.config.stateFile); - const record = readAgentRecord(stateDir); - // If the record exists and the PID is alive, the agent is healthy - // (or at least still answering signal 0). Slow-but-alive agents - // intentionally fall through here — split-brain is worse than - // unresponsiveness, and slow recovery is handled by the user via - // restart. - if (record && isProcessAlive(record.pid)) return; - // Either no record (never spawned, or cleaned up after crash) or - // PID is dead. Try to respawn. - const now = Date.now(); - while (respawnHistory.length && now - respawnHistory[0] > RESPAWN_GUARD_WINDOW_MS) { - respawnHistory.shift(); - } - if (respawnHistory.length >= RESPAWN_GUARD_MAX) { - agentRespawnGuardTripped = true; - console.error( - `[browse] terminal-agent respawn guard tripped (${RESPAWN_GUARD_MAX} crashes in ${RESPAWN_GUARD_WINDOW_MS / 1000}s) — manual restart required`, - ); - return; - } - respawnHistory.push(now); - try { - const pid = spawnTerminalAgent({ - stateFile: cfg.config.stateFile, - serverPort: cfg.browsePort, - cwd: cfg.config.projectDir, - }); - if (pid) { - console.log(`[browse] terminal-agent respawned by watchdog (PID: ${pid})`); - } else { - console.warn('[browse] terminal-agent respawn skipped — script not found on disk'); - } - } catch (err: any) { - console.warn('[browse] terminal-agent respawn failed:', err?.message || err); - } - }, AGENT_WATCHDOG_TICK_MS); - // Detach the watchdog timer from Node's event-loop ref count so a - // healthy idle process can still exit cleanly if everything else is - // also unref'd. Bun's setInterval returns a Timer with unref(). - (agentWatchdogInterval as any)?.unref?.(); - } // Factory-scoped validateAuth. Closes over cfg.authToken so every internal // auth check sees the same token the routes receive. Module-level @@ -1595,25 +1397,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { // a daemon that no longer exists. The path must come from this factory's // config so embedded/isolated servers never clean a sibling session. const shutdownStateFile = cfg.config.stateFile; - const shutdownStateDir = path.dirname(shutdownStateFile); safeUnlinkQuiet(shutdownStateFile); console.log('[browse] Shutting down...'); - if (ownsTerminalAgent) { - // Identity-based kill (v1.44+). Replaces the v1.43- `pkill -f - // terminal-agent\.ts` regex teardown which matched sibling gstack - // sessions on the same host. Only the PID recorded in - // `/terminal-agent-pid` by THIS daemon's agent is signaled. - try { - const record = readAgentRecord(shutdownStateDir); - if (record) killAgentByRecord(record, 'SIGTERM'); - } catch (err: any) { - console.warn('[browse] Failed to kill terminal-agent:', err.message); - } - safeUnlinkQuiet(path.join(shutdownStateDir, 'terminal-port')); - safeUnlinkQuiet(path.join(shutdownStateDir, 'terminal-internal-token')); - safeUnlinkQuiet(agentRecordPath(shutdownStateDir)); - } try { detachSession(); } catch (err: any) { console.warn('[browse] Failed to detach CDP session:', err.message); } @@ -1621,7 +1407,6 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { if (cfgBrowserManager.isWatching()) cfgBrowserManager.stopWatch(); clearInterval(flushInterval); clearInterval(idleCheckInterval); - if (agentWatchdogInterval) clearInterval(agentWatchdogInterval); await flushBuffers(); await cfgBrowserManager.close(); @@ -1815,237 +1600,12 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { // sidebar-agent.ts was ripped; only the page-content side // (canary, content-security) keeps reporting in. 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 sessionId + lease + attachToken ───────── - // - // v1.44+ four-tuple shape: - // { terminalPort, sessionId, attachToken, leaseExpiresAt } - // - // - sessionId : stable, non-secret. Safe to log. Identifies "this - // terminal" across re-attaches. - // - attachToken : short-lived (30 min wall, single attach in practice - // since the agent revokes on WS close). Bearer for - // the /ws upgrade. - // - leaseExpiresAt: client-visible deadline for the lease. Re-attach - // only works inside this window. - // - // The lease + attachToken are minted together so a successful - // /pty-session is one round trip. Re-attach mints a fresh attachToken - // for the SAME sessionId via /pty-session/reattach. - // - // 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 lease = mintLease(); - const minted = mintPtySessionToken(); - const granted = await grantPtyToken(minted.token, lease.sessionId); - if (!granted) { - revokePtySessionToken(minted.token); - revokeLease(lease.sessionId); - 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, - sessionId: lease.sessionId, - attachToken: minted.token, - leaseExpiresAt: lease.expiresAt, - // Legacy alias — extensions still on the v1.43 wire shape keep - // working. Drop after one minor release once dogfood confirms. - ptySessionToken: minted.token, - expiresAt: minted.expiresAt, - }), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Set-Cookie': buildPtySetCookie(minted.token), - }, - }); - } - - // ─── /pty-session/reattach — mint fresh attachToken for existing sessionId - // - // Used by Commit 3's re-attach loop on the client. Validates the - // lease (rejects unknown/expired sessionId with 410 Gone), mints a - // fresh short-lived attachToken bound to the same sessionId, and - // pushes it to the agent. The client opens a new WS with the new - // token; the agent matches the sessionId binding and re-attaches - // to the existing PtySession (kept alive for the 60s detach - // window — Commit 3 wires that side). - if (url.pathname === '/pty-session/reattach' && 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' }, - }); - } - let body: any; - try { body = await req.json(); } catch { body = null; } - const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null; - const v = sessionId ? validateLease(sessionId) : { ok: false }; - if (!v.ok) { - // 410 Gone — session window has closed (lease expired or never - // existed). Client must fall back to /pty-session for a brand-new - // session. - return new Response(JSON.stringify({ error: 'lease expired or unknown' }), { - status: 410, headers: { 'Content-Type': 'application/json' }, - }); - } - const minted = mintPtySessionToken(); - const granted = await grantPtyToken(minted.token, sessionId!); - if (!granted) { - revokePtySessionToken(minted.token); - return new Response(JSON.stringify({ error: 'failed to grant attach token' }), { - status: 503, headers: { 'Content-Type': 'application/json' }, - }); - } - return new Response(JSON.stringify({ - terminalPort: port, - sessionId, - attachToken: minted.token, - leaseExpiresAt: v.ok ? v.expiresAt : 0, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - } - - // ─── /pty-restart — one-transaction kill + fresh mint ──────────── - // - // The Restart button. Synchronously disposes the caller's existing - // PtySession on the agent, revokes the old lease, mints a fresh - // sessionId + lease + attachToken, and returns the new 4-tuple in - // one response. Zero race window between kill and mint (codex T2 - // + D8 of the eng review). - if (url.pathname === '/pty-restart' && 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' }, - }); - } - let body: any; - try { body = await req.json(); } catch { body = null; } - const oldSessionId = typeof body?.sessionId === 'string' ? body.sessionId : null; - // Best-effort dispose. Missing/unknown sessionId is non-fatal — - // the client may be doing a "restart from scratch" with no prior - // session (e.g. ENDED state). The fresh mint always proceeds. - if (oldSessionId) { - await restartPtySession(oldSessionId); - revokeLease(oldSessionId); - } - const lease = mintLease(); - const minted = mintPtySessionToken(); - const granted = await grantPtyToken(minted.token, lease.sessionId); - if (!granted) { - revokePtySessionToken(minted.token); - revokeLease(lease.sessionId); - 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, - sessionId: lease.sessionId, - attachToken: minted.token, - leaseExpiresAt: lease.expiresAt, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - } - - // ─── /pty-dispose — explicit teardown (pagehide / browser quit) ── - // - // sendBeacon-compatible: accepts the auth token in the BODY so the - // extension's pagehide handler can fire it without setting headers - // (sendBeacon doesn't support custom headers). Codex T3 fix — - // without this, every browser quit + sidebar close leaves a zombie - // PTY alive for the 60s detach window (Commit 3). - if (url.pathname === '/pty-dispose' && req.method === 'POST') { - let body: any; - try { body = await req.json(); } catch { body = null; } - const authTokenFromBody = typeof body?.authToken === 'string' ? body.authToken : null; - // Accept either header bearer OR body authToken. Both must match - // the root auth token; otherwise reject. - const headerToken = extractToken(req); - const authedByHeader = headerToken !== null && headerToken === authToken; - const authedByBody = authTokenFromBody !== null && authTokenFromBody === authToken; - if (!authedByHeader && !authedByBody) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, headers: { 'Content-Type': 'application/json' }, - }); - } - const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null; - if (sessionId) { - await restartPtySession(sessionId); - revokeLease(sessionId); - } - return new Response(JSON.stringify({ ok: true }), { - status: 200, headers: { 'Content-Type': 'application/json' }, - }); - } - - // ─── /internal/lease-refresh — loopback from terminal-agent on keepalive - // - // T6 PTY-only idle reset (codex outside-voice fix): the headless - // daemon's idle timer must reset only on active PTY usage, not on - // every passive SSE consumer. Terminal-agent calls this endpoint - // (lazily, only when its cached lease is within 5 min of expiry) - // on its 25s keepalive cycle. Refreshing the lease here also bumps - // lastActivity so the daemon stays alive while a sidebar terminal - // is actively in use. - // - // INTERNAL endpoint — bound to the root authToken so an external - // caller can't refresh another user's lease. Body: {sessionId}. - if (url.pathname === '/internal/lease-refresh' && req.method === 'POST') { - if (!validateAuth(req)) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, headers: { 'Content-Type': 'application/json' }, - }); - } - let body: any; - try { body = await req.json(); } catch { body = null; } - const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null; - const r = sessionId ? refreshLease(sessionId) : { ok: false }; - if (!r.ok) { - return new Response(JSON.stringify({ error: 'lease expired or unknown' }), { - status: 410, headers: { 'Content-Type': 'application/json' }, - }); - } - // T6: PTY activity resets the daemon idle timer. - resetIdleTimer(); - return new Response(JSON.stringify({ ok: true, expiresAt: r.expiresAt }), { - status: 200, headers: { 'Content-Type': 'application/json' }, - }); - } - // ─── /pty-inject-scan — pre-inject prompt-injection scan for the // extension's gstackInjectToTerminal callers. The extension routes // every page-derived text through this endpoint BEFORE writing to @@ -3007,7 +2567,6 @@ export async function start() { xvfb, proxyBridge, startTime, - ownsTerminalAgent: true, // CLI spawns terminal-agent.ts itself (see cli.ts:1037-1063) }); const server = Bun.serve({ diff --git a/browse/src/sidebar-utils.ts b/browse/src/sidebar-utils.ts deleted file mode 100644 index c5ff201d0f..0000000000 --- a/browse/src/sidebar-utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Shared sidebar utilities — extracted for testability. - */ - -/** - * Sanitize a URL from the Chrome extension before embedding in a prompt. - * Only accepts http/https, strips control characters, truncates to 2048 chars. - * Returns null if the URL is invalid or uses a non-http scheme. - */ -export function sanitizeExtensionUrl(url: string | null | undefined): string | null { - if (!url) return null; - try { - const u = new URL(url); - if (u.protocol === 'http:' || u.protocol === 'https:') { - return u.href.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 2048); - } - return null; - } catch { - return null; - } -} diff --git a/browse/src/terminal-agent-control.ts b/browse/src/terminal-agent-control.ts deleted file mode 100644 index 094ba668fa..0000000000 --- a/browse/src/terminal-agent-control.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * terminal-agent process-control primitives shared by cli.ts spawn site, - * server.ts shutdown teardown, and the v1.44 watchdog/respawn loop. - * - * Why this exists: pre-v1.44 used `pkill -f terminal-agent\.ts`, which - * matches any process whose argv contains the string and would kill - * sibling gstack sessions on the same host. The agent now writes a - * structured `terminal-agent-pid` record (`{pid, gen, startedAt}`) and - * every kill site routes through `killAgentByRecord` here — identity-based, - * no regex. - * - * The `gen` field is a per-boot generation counter. Loopback /internal/* - * calls from the parent server include `X-Browse-Gen` so a slow agent that - * the watchdog respawned around can't accidentally service a stale grant - * from the old generation. - */ -import * as fs from 'fs'; -import * as path from 'path'; -import { safeUnlink, safeKill, isProcessAlive } from './error-handling'; -import { writeSecureFile, mkdirSecure } from './file-permissions'; - -/** - * Locate the terminal-agent script on disk. In dev (cli.ts running via - * `bun run`), it lives next to this file in browse/src. In a compiled - * binary, Bun's --compile bakes the source into the executable and - * exposes it relative to process.execPath. Either path must work or - * the agent can't be spawned at all. - */ -export function resolveTerminalAgentScript(searchHints: { metaDir?: string; execPath?: string } = {}): string | null { - const meta = searchHints.metaDir || __dirname; - const exec = searchHints.execPath || process.execPath; - const candidates = [ - path.resolve(meta, 'terminal-agent.ts'), - path.resolve(path.dirname(exec), '..', 'src', 'terminal-agent.ts'), - ]; - for (const c of candidates) { - if (fs.existsSync(c)) return c; - } - return null; -} - -/** - * Spawn a fresh terminal-agent as a detached child. Handles the standard - * three steps: kill any prior agent recorded at `/terminal-agent-pid`, - * clear the stale record, then `Bun.spawn(['bun', 'run', script], ...)` with - * env wiring. Returns the PID of the new agent on success, null when the - * agent script can't be located. - * - * Used by both the CLI cold-start path (cli.ts) and the v1.44 watchdog in - * server.ts. Centralizing here removes a copy-paste between them and means - * future spawn-env additions (e.g. BROWSE_OWNER_PID for the generation - * counter rollout) land in one place. - */ -export function spawnTerminalAgent(opts: { - stateFile: string; - serverPort: number; - cwd?: string; - /** Optional extra env vars to add to the agent's process env. */ - extraEnv?: Record; - /** Override script lookup for tests. */ - scriptPath?: string; -}): number | null { - const stateDir = path.dirname(opts.stateFile); - const prior = readAgentRecord(stateDir); - if (prior) { - killAgentByRecord(prior, 'SIGTERM'); - clearAgentRecord(stateDir); - } - const script = opts.scriptPath || resolveTerminalAgentScript(); - if (!script || !fs.existsSync(script)) return null; - const proc = (Bun as any).spawn(['bun', 'run', script], { - cwd: opts.cwd || process.cwd(), - env: { - ...process.env, - BROWSE_STATE_FILE: opts.stateFile, - BROWSE_SERVER_PORT: String(opts.serverPort), - ...(opts.extraEnv || {}), - }, - stdio: ['ignore', 'ignore', 'ignore'], - }); - proc.unref?.(); - return proc.pid ?? null; -} - -export interface AgentRecord { - pid: number; - /** Random per-boot identifier. Loopback /internal/* sees X-Browse-Gen: . */ - gen: string; - /** ms since epoch. Reserved for future PID-reuse guards. */ - startedAt: number; -} - -export function agentRecordPath(stateDir: string): string { - return path.join(stateDir, 'terminal-agent-pid'); -} - -/** Read the current record. Returns null on missing/malformed file. */ -export function readAgentRecord(stateDir: string): AgentRecord | null { - try { - const raw = fs.readFileSync(agentRecordPath(stateDir), 'utf-8'); - const j = JSON.parse(raw); - if (typeof j?.pid === 'number' && typeof j?.gen === 'string' && typeof j?.startedAt === 'number') { - return j as AgentRecord; - } - return null; - } catch { - return null; - } -} - -/** Atomic write. Caller must ensure stateDir exists; agent does this at boot. */ -export function writeAgentRecord(stateDir: string, record: AgentRecord): void { - try { mkdirSecure(stateDir); } catch {} - const target = agentRecordPath(stateDir); - const tmp = `${target}.tmp-${process.pid}`; - writeSecureFile(tmp, JSON.stringify(record)); - fs.renameSync(tmp, target); -} - -export function clearAgentRecord(stateDir: string): void { - safeUnlink(agentRecordPath(stateDir)); -} - -/** - * Kill the agent identified by `record`. Signal defaults to SIGTERM (give - * the agent a chance to run its own SIGTERM cleanup). Returns true if a - * signal was actually sent to a live PID; false if the PID was already - * dead (no-op). Never throws — ESRCH is swallowed by safeKill. - * - * Validates liveness BEFORE signaling so a PID-reuse race (the recorded - * PID was reaped and a brand-new unrelated process now holds it) can't - * cause us to kill the wrong process. This is a best-effort defense: - * Linux/macOS don't expose process-start-time cheaply, and the gap - * between record-write and watchdog-tick is small (60s max). - */ -export function killAgentByRecord( - record: AgentRecord, - signal: NodeJS.Signals = 'SIGTERM', -): boolean { - if (!isProcessAlive(record.pid)) return false; - safeKill(record.pid, signal); - return true; -} diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts deleted file mode 100644 index 2e39d99e40..0000000000 --- a/browse/src/terminal-agent.ts +++ /dev/null @@ -1,1011 +0,0 @@ -/** - * 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 { writeSecureFile, mkdirSecure } from './file-permissions'; -import { safeUnlink } from './error-handling'; -import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control'; - -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 -/** - * Per-boot generation identifier. Loopback /internal/* callers include - * `X-Browse-Gen: ` so a slow agent the watchdog respawned - * around can't service a stale grant from the prior generation. Absent - * header means "legacy caller" and is accepted (backward compat); a - * present-but-mismatched header returns 409 stale generation. - */ -const CURRENT_GEN = crypto.randomBytes(16).toString('base64url'); - -// In-memory attach-token registry. Parent posts /internal/grant after -// /pty-session; we validate WS upgrades against this map. -// -// v1.44+: each token is bound to a v1.44 sessionId (the stable, non-secret -// identifier from browse/src/pty-session-lease.ts). The token grants ONE -// attach for ONE session — re-attach within the lease window comes through -// /pty-session/reattach, which mints a fresh token for the same sessionId. -// -// Legacy callers can still pass `{token}` without sessionId (the value -// stays null and the WS upgrade still works); those callers don't get -// re-attach because there's no stable identifier to match against. -const validTokens = new Map(); // token → sessionId - -/** - * Reverse index for re-attach lookups: sessionId → live PtySession. - * Populated when a WS first attaches with a known sessionId; cleared when - * the session is disposed or the lease expires. Used by: - * - /ws upgrade: if the incoming attachToken maps to a sessionId that - * already has a live session, REPLACE its ws ref instead of spawning. - * - /internal/restart: enumerate by sessionId, dispose that one session. - * - * Kept separate from the WeakMap so re-attach can find the - * session by id even after the original ws has gone. - */ -const sessionsById = new Map(); - -// 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); -}); - -export interface PtySession { - proc: any | null; // Bun.Subprocess once spawned - cols: number; - rows: number; - cookie: string; - /** - * Current attached websocket. Swapped on re-attach (Commit 3): when a new - * WS upgrade matches this session's sessionId, the old liveWs is gone - * and the new ws takes its place. The PTY on-data callback closes over - * `session`, not the original `ws`, so it always writes to the current - * liveWs (or skips the write when detached and liveWs is null). - */ - liveWs: any | null; - /** - * v1.44+ stable session identifier (from pty-session-lease). Null for - * legacy /internal/grant callers that didn't pass one. Used for - * targeted /internal/restart and Commit 3 re-attach lookups. - */ - sessionId: string | null; - spawned: boolean; - /** - * 25s server-side WS keepalive interval (v1.44+). Set in the WS `open` - * handler, cleared in `close`. We send `{type:"ping",ts}` text frames so - * NAT boxes, proxies, and Chrome's MV3 panel-suspend heuristics see the - * connection as active; the client either replies with `{type:"pong"}` - * or fires its own 25s `{type:"keepalive"}` cycle. Either path keeps - * the underlying TCP from being silently dropped. - */ - pingInterval: ReturnType | null; - /** - * Commit 3 scrollback ring buffer. Each PTY write appends a frame; the - * total byte count is capped at RING_BUFFER_MAX_BYTES with oldest frames - * evicted first. On re-attach, the surviving frames are replayed as a - * single binary frame (prefixed with the v1.44 reset sequence) so the - * user sees their last screen of output. Frame boundaries preserve UTF-8 - * + ANSI-CSI boundaries because each frame is the exact buffer that - * spawnClaude's on-data callback emitted. - */ - ringBuffer: Buffer[]; - ringBufferBytes: number; - /** - * Tracks whether the PTY is currently in xterm alt-screen mode. claude's - * TUI enters alt-screen (CSI ?1049h) during tool calls and exits (CSI - * ?1049l) when returning to the main prompt. On re-attach, the replay - * prelude must re-enter alt-screen if the original PTY left it active, - * otherwise the replay renders against the main screen and the cursor - * + colors end up in the wrong place. - */ - altScreenActive: boolean; - /** - * Detach state machine (Commit 3). When the WS closes for a reason OTHER - * than the v1.44 intentional-restart code (4001), we keep the PtySession - * alive for the detach window (default 60s) so a re-attach within the - * window can resume the same PTY and replay the ring buffer. The timer - * disposes the session if no re-attach arrives in time. - */ - detached: boolean; - detachTimer: ReturnType | null; -} - -/** - * WS keepalive interval. 25s is comfortably under the lowest common NAT - * idle timeout (typically 30-60s) and shorter than Chromium's WebSocket - * dead-peer threshold. Test-overridable via env so the v1.44 e2e tests - * can compress idle-window assertions to <1s without waiting half a - * minute per assertion. - */ -const KEEPALIVE_INTERVAL_MS = parseInt( - process.env.GSTACK_PTY_KEEPALIVE_INTERVAL_MS || '25000', - 10, -); - -/** - * Commit 3 scrollback ring buffer cap. 1 MB is enough for a full screen - * of dense claude output (including a recent tool result), small enough - * that a worst-case 10 detached sessions only cost ~10 MB of RSS. - * Env-overridable so e2e tests can verify eviction without writing 1 MB - * of fixture data per assertion. - */ -const RING_BUFFER_MAX_BYTES = parseInt( - process.env.GSTACK_PTY_RING_BUFFER_BYTES || `${1024 * 1024}`, - 10, -); - -/** - * Commit 3 detach window — how long to keep a session alive after WS - * close (with any code other than 4001 intentional-restart) so a - * re-attach can resume the same PTY. 60s is long enough to cover a - * Chrome MV3 service-worker suspend cycle, a wifi blip, or a brief - * laptop sleep; short enough that genuinely-closed sessions don't - * stack up unbounded. - */ -const DETACH_WINDOW_MS = parseInt( - process.env.GSTACK_PTY_DETACH_WINDOW_MS || '60000', - 10, -); - -/** - * Append a frame to a session's ring buffer, evicting oldest frames if - * the total byte count exceeds RING_BUFFER_MAX_BYTES. Eviction is at - * frame boundaries (one PTY write = one frame), so we never cut a - * multi-byte UTF-8 sequence or a partial ANSI CSI in half — claude's - * on-data callback emits coherent frames. - * - * Side effect: scans the appended chunk for alt-screen enter/exit - * sequences (CSI ?1049h / CSI ?1049l) and updates session.altScreenActive - * so the re-attach prelude knows whether to re-enter alt-screen. - */ -export function appendToRingBuffer(session: PtySession, frame: Buffer): void { - session.ringBuffer.push(frame); - session.ringBufferBytes += frame.length; - while (session.ringBufferBytes > RING_BUFFER_MAX_BYTES && session.ringBuffer.length > 1) { - const evicted = session.ringBuffer.shift()!; - session.ringBufferBytes -= evicted.length; - } - // Alt-screen tracking. Scan for the canonical xterm enter/exit pairs. - // We do this on every append (not just on attach) so the state is - // correct even if many frames have flowed since the last attach. - const ascii = frame.toString('latin1'); // single-byte view is enough — the codes are 7-bit ASCII - // Use lastIndexOf so trailing state wins when both appear in one frame - // (e.g., a quick tool-call open+close inside one render pass). - const enterIdx = ascii.lastIndexOf('\x1b[?1049h'); - const exitIdx = ascii.lastIndexOf('\x1b[?1049l'); - if (enterIdx >= 0 && enterIdx > exitIdx) session.altScreenActive = true; - else if (exitIdx >= 0 && exitIdx > enterIdx) session.altScreenActive = false; -} - -/** - * Build the re-attach replay payload: server-side reset prelude + the - * accumulated ring buffer. The client side writes RIS (`\x1bc`) to xterm - * BEFORE feeding this payload in, so the layout is: - * - * 1. Client: `\x1bc` (RIS — full reset, clears pre-blip xterm content) - * 2. Server: `\x1b[!p` (DECSTR soft reset — re-defaults char attributes) - * 3. Server: optional `\x1b[?1049h` if we were in alt-screen at detach - * 4. Server: ring buffer contents, in append order - * - * The client coordinates the order by waiting for a `{type:"reattach-begin"}` - * text frame before treating the next binary frame as replay. That separation - * is what lets us prepend reset codes without clobbering the live stream - * that resumes immediately after. - */ -export function buildReplayPayload(session: PtySession): Buffer { - const parts: Buffer[] = []; - parts.push(Buffer.from('\x1b[!p')); - if (session.altScreenActive) parts.push(Buffer.from('\x1b[?1049h')); - for (const frame of session.ringBuffer) parts.push(frame); - return Buffer.concat(parts); -} - -const sessions = new WeakMap(); // 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 { mkdirSecure(stateDir); } 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 { - writeSecureFile(tmp, JSON.stringify(status, null, 2)); - 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 /tabs.json + active-tab.json - * (updated continuously by the gstack browser extension). - * 2. Run $B tab, $B tabs, $B tab-each 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 — switch to a tab', - ' $B newtab [url] — open a new tab', - ' $B closetab [id] — close a tab (current if no id)', - ' $B tab-each — 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 = { - ...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. - */ -/** - * Validate a loopback /internal/* request. Returns null when the request - * is allowed; otherwise returns the Response to send back. Centralizes - * bearer auth + the v1.44 X-Browse-Gen generation check so adding a new - * /internal/* route is a one-liner. - */ -function checkInternalAuth(req: Request): Response | null { - const auth = req.headers.get('authorization'); - if (auth !== `Bearer ${INTERNAL_TOKEN}`) { - return new Response('forbidden', { status: 403 }); - } - const headerGen = req.headers.get('x-browse-gen'); - if (headerGen && headerGen !== CURRENT_GEN) { - return new Response('stale generation', { status: 409 }); - } - return null; -} - -/** - * Wrap a JSON-bodied /internal/* handler with the standard bearer-auth + - * generation-check + json-parse + error-response boilerplate. The handler - * `fn` is called with the parsed body; whatever it returns is JSON-stringified - * into a 200 Response, or the handler can return a Response directly to - * customize status / headers. Throwing from `fn` collapses to a 400 "bad". - * - * Centralizing the dance kills the copy-paste pattern of bearer + gen check - * + req.json().then(...).catch(...) that every /internal/* route needs. - * New routes become a single call to internalHandler. - */ -async function internalHandler( - req: Request, - fn: (body: any) => T | Promise | Response | Promise, -): Promise { - const denied = checkInternalAuth(req); - if (denied) return denied; - let body: any; - try { - body = await req.json(); - } catch { - return new Response('bad', { status: 400 }); - } - try { - const result = await fn(body); - if (result instanceof Response) return result; - if (result === undefined || result === null) return new Response('ok'); - return new Response(JSON.stringify(result), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } catch { - return new Response('bad', { status: 400 }); - } -} - -/** - * Spawn the claude PTY for a session if it hasn't been spawned yet. - * Used by both the legacy binary-frame spawn trigger and the v1.44 explicit - * `{type:"start"}` text-frame trigger. Idempotent on `session.spawned`. - * - * Returns true if claude is now running, false if spawn failed (e.g. claude - * binary not on PATH). On failure, the caller is expected to have already - * surfaced the error to the client (or will via the next frame). - */ -function maybeSpawnPty(ws: any, session: PtySession): boolean { - if (session.spawned) return true; - session.spawned = true; - let leftover = Buffer.alloc(0); - const proc = spawnClaude(session.cols, session.rows, (chunk) => { - const combined = Buffer.concat([leftover, Buffer.from(chunk)]); - // UTF-8 boundary detection (issue #1272). Look back at most 3 bytes - // for the start of an incomplete multibyte sequence and defer it. - let safeEnd = combined.length; - for (let i = combined.length - 1; i >= Math.max(0, combined.length - 3); i--) { - const b = combined[i]; - if ((b & 0x80) === 0) { safeEnd = i + 1; break; } - if ((b & 0xC0) === 0x80) continue; - const expected = (b & 0xE0) === 0xC0 ? 2 : (b & 0xF0) === 0xE0 ? 3 : 4; - safeEnd = (combined.length - i >= expected) ? combined.length : i; - break; - } - const flush = combined.slice(0, safeEnd); - leftover = combined.slice(safeEnd); - if (flush.length) { - // Always record into the ring buffer (Commit 3) so re-attach can - // replay. session.liveWs is what changes across re-attaches — we - // close over `session`, not the original `ws`, so the write always - // goes to whichever ws is currently attached (or is skipped when - // detached and liveWs is null). - appendToRingBuffer(session, flush); - if (session.liveWs) { - try { session.liveWs.sendBinary(flush); } 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 false; - } - session.proc = proc; - proc.exited?.then?.(() => { - try { session.liveWs?.close(1000, 'pty exited'); } catch {} - }); - return true; -} - -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. - // v1.44+: accepts `{token, sessionId?}`. The sessionId binding lets - // the agent route re-attach attempts (same sessionId, fresh token) - // back to the same PtySession. Legacy callers passing just `{token}` - // still work — sessionId becomes null and re-attach is unavailable - // for that grant. - if (url.pathname === '/internal/grant' && req.method === 'POST') { - return internalHandler(req, (body) => { - if (typeof body?.token === 'string' && body.token.length > 16) { - const sid = typeof body?.sessionId === 'string' && body.sessionId.length > 0 - ? body.sessionId - : null; - validTokens.set(body.token, sid); - } - }); - } - - // /internal/revoke — drop a token (called on WS close or bootstrap reload) - if (url.pathname === '/internal/revoke' && req.method === 'POST') { - return internalHandler(req, (body) => { - if (typeof body?.token === 'string') validTokens.delete(body.token); - }); - } - - // /internal/restart — dispose the PtySession for a specific sessionId. - // Scoped to one caller (not enumerate-all). Server.ts /pty-restart - // posts here with the caller's sessionId; we kill ONLY that PTY, - // leaving any other live sidebar tabs untouched. Codex T2 of the - // eng review caught this gap — pre-spec the route would have - // disposed all sessions. - if (url.pathname === '/internal/restart' && req.method === 'POST') { - return internalHandler(req, (body) => { - const sid = typeof body?.sessionId === 'string' ? body.sessionId : null; - if (!sid) return { killed: 0 }; - const session = sessionsById.get(sid); - if (!session) return { killed: 0 }; - // Cancel any pending detach timer before disposal — otherwise it - // would fire later against an already-disposed session. - if (session.detachTimer) { - clearTimeout(session.detachTimer); - session.detachTimer = null; - } - disposeSession(session); - sessionsById.delete(sid); - return { killed: 1 }; - }); - } - - // /internal/healthz — liveness probe used by the v1.44 watchdog. - // Returns this agent's pid + gen + active session count without - // touching claude binary lookup (which can fail for non-process - // reasons and isn't a useful liveness signal). GET — no body to parse, - // so it stays on the bare checkInternalAuth gate. - if (url.pathname === '/internal/healthz' && req.method === 'GET') { - const denied = checkInternalAuth(req); - if (denied) return denied; - return new Response(JSON.stringify({ - pid: process.pid, - gen: CURRENT_GEN, - sessions: validTokens.size, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - } - - // /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://. 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 }); - } - - // v1.44+: surface the token's sessionId binding to the upgraded ws. - // open() reads it via ws.data and registers the session in - // sessionsById so /internal/restart and (Commit 3) re-attach - // lookups can find it. - const sessionId = validTokens.get(token) ?? null; - const upgraded = server.upgrade(req, { - data: { cookie: token, sessionId }, - // 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: { - /** - * Spawn the claude PTY for `session` if it hasn't been spawned yet. - * Called from both message paths: the legacy binary-frame trigger - * (any keystroke) AND the v1.44 explicit `{type:"start"}` trigger - * (forceRestart sends this on every fresh WS to get an eager prompt - * without requiring the user to type). Idempotent — a second call - * after `spawned: true` is a no-op. - */ - open(ws) { - const sessionId = (ws.data as any)?.sessionId ?? null; - const cookie = (ws.data as any)?.cookie || ''; - - // Commit 3 re-attach: if this sessionId already has a detached - // PtySession in sessionsById, REPLACE its liveWs ref and replay - // the ring buffer. The PTY process is unchanged — claude keeps - // running through the wifi blip / panel-suspend cycle. - if (sessionId) { - const existing = sessionsById.get(sessionId); - if (existing) { - if (existing.detachTimer) { - clearTimeout(existing.detachTimer); - existing.detachTimer = null; - } - existing.detached = false; - existing.liveWs = ws; - existing.cookie = cookie; - // Re-bind the WS-keyed map so resize/close/message handlers - // can still find this session via the new ws. - sessions.set(ws, existing); - // Restart keepalive on the new ws. - if (existing.pingInterval) clearInterval(existing.pingInterval); - existing.pingInterval = setInterval(() => { - try { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } catch {} - }, KEEPALIVE_INTERVAL_MS); - // Tell the client to prep its xterm (write RIS) before the - // replay binary arrives. Order matters — the binary frame - // immediately after this text frame IS the replay. - try { ws.send(JSON.stringify({ type: 'reattach-begin', sessionId })); } catch {} - try { ws.sendBinary(buildReplayPayload(existing)); } catch {} - return; - } - } - - const session: PtySession = { - proc: null, - cols: 80, - rows: 24, - cookie, - liveWs: ws, - sessionId, - spawned: false, - pingInterval: null, - ringBuffer: [], - ringBufferBytes: 0, - altScreenActive: false, - detached: false, - detachTimer: null, - }; - session.pingInterval = setInterval(() => { - try { - ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); - } catch { - // ws likely closed mid-tick; close handler clears the interval. - } - }, KEEPALIVE_INTERVAL_MS); - sessions.set(ws, session); - // Index by sessionId for /internal/restart + Commit 3 re-attach. - if (sessionId) sessionsById.set(sessionId, session); - }, - - message(ws, raw) { - let session = sessions.get(ws); - if (!session) { - // Fallback for any path where open() didn't fire (shouldn't happen - // in Bun.serve but keeps the spawn path safe). No keepalive on - // this branch — open() is the supported entry point. - session = { - proc: null, - cols: 80, - rows: 24, - cookie: (ws.data as any)?.cookie || '', - liveWs: ws, - sessionId: (ws.data as any)?.sessionId ?? null, - spawned: false, - pingInterval: null, - ringBuffer: [], - ringBufferBytes: 0, - altScreenActive: false, - detached: false, - detachTimer: null, - }; - sessions.set(ws, session); - if (session.sessionId) sessionsById.set(session.sessionId, session); - } - - // Text frames are control messages: {type: "resize", cols, rows}, - // {type: "tabSwitch", tabId, url, title}, {type: "tabState", ...}, - // or v1.44 keepalive frames: {type: "pong", ts}, {type: "keepalive"}. - // 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; - } - if (msg?.type === 'pong' || msg?.type === 'keepalive' || msg?.type === 'ping') { - // Keepalive frames — accepted and silently dropped. The mere - // fact that the WS carried this frame is the liveness signal; - // there's no application-level state to update at this layer. - // `ping` is acknowledged here too in case the client (or a - // future agent peer) mirrors our server-side ping shape. - return; - } - if (msg?.type === 'start') { - // v1.44 explicit spawn trigger. forceRestart sends this - // immediately on every fresh WS so claude boots without the - // user having to type a keystroke (pre-v1.44, the lazy-binary - // spawn made restart look stuck until the user typed). No-op - // if already spawned. - maybeSpawnPty(ws, session); - return; - } - // Unknown text frame — ignore. - return; - } - - // Binary input. Lazy-spawn claude on the first byte if `start` - // wasn't sent first. Both paths land in the same maybeSpawnPty - // helper for behavior parity. - if (!session.spawned) { - if (!maybeSpawnPty(ws, session)) return; - } - 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, code, _reason) { - const session = sessions.get(ws); - if (!session) return; - // Always drop the WS-keyed map entry and the per-attach - // attachToken — the attach grant was single-use. - sessions.delete(ws); - if (session.cookie) validTokens.delete(session.cookie); - // Keepalive lives with the WS — every attach starts a fresh one. - if (session.pingInterval) { - clearInterval(session.pingInterval); - session.pingInterval = null; - } - - // Commit 3 detach state machine. If the close was intentional - // (code 4001 = restart, 4404 = no-claude error), dispose - // immediately — there's no value in keeping the PTY alive. - // Otherwise enter the detach window: claude keeps running, the - // ring buffer keeps accumulating, and a re-attach with the same - // sessionId within DETACH_WINDOW_MS picks back up. If the timer - // fires without a re-attach, the session is disposed normally. - // - // Sessions without a sessionId (legacy single-shot grants) can't - // re-attach by definition — fall through to immediate dispose. - const intentional = code === 4001 || code === 4404 || code === 1000; - if (intentional || !session.sessionId) { - disposeSession(session); - if (session.sessionId) sessionsById.delete(session.sessionId); - return; - } - - // Mark detached and start the disposal timer. The session stays - // in sessionsById so the next /ws upgrade with the same - // sessionId can find and reattach to it. - session.detached = true; - session.liveWs = null; - session.detachTimer = setTimeout(() => { - if (!session.detached) return; // re-attached in the meantime - disposeSession(session); - if (session.sessionId) sessionsById.delete(session.sessionId); - }, DETACH_WINDOW_MS); - // setTimeout returns a Bun Timer; unref so the detach window - // doesn't keep the process alive past natural shutdown. - (session.detachTimer as any)?.unref?.(); - }, - }, - }); -} - -/** - * 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 /tabs.json (full list) and updates - * /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 { mkdirSecure(stateDir); } 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 { - writeSecureFile(tmp, JSON.stringify(payload, null, 2)); - 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 { - writeSecureFile(tmp, JSON.stringify({ - tabId: active.tabId ?? null, - url: active.url, - title: active.title ?? '', - })); - 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 { - writeSecureFile(tmp, JSON.stringify({ - tabId: msg.tabId ?? null, - url, - title: msg.title ?? '', - })); - 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 { mkdirSecure(dir); } catch {} - const tmp = `${PORT_FILE}.tmp-${process.pid}`; - writeSecureFile(tmp, String(port)); - fs.renameSync(tmp, PORT_FILE); - - // Write identity-based agent record (pid + per-boot gen). Replaces the - // v1.43- `pkill -f terminal-agent\.ts` regex teardown that could kill - // sibling gstack sessions. Callers (cli.ts spawn site, server.ts - // shutdown, the v1.44 watchdog) now route through killAgentByRecord in - // terminal-agent-control.ts. - writeAgentRecord(dir, { pid: process.pid, gen: CURRENT_GEN, startedAt: Date.now() }); - - // 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} gen=${CURRENT_GEN}`); - - // Cleanup port file + agent record on exit. - const cleanup = () => { - safeUnlink(PORT_FILE); - clearAgentRecord(dir); - 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 { - mkdirSecure(path.dirname(INTERNAL_TOKEN_FILE)); - writeSecureFile(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN); -} catch {} - -main(); diff --git a/browse/test/dual-listener.test.ts b/browse/test/dual-listener.test.ts index 9ee1a5f29a..2237461f77 100644 --- a/browse/test/dual-listener.test.ts +++ b/browse/test/dual-listener.test.ts @@ -48,9 +48,9 @@ describe('Dual-listener surface types', () => { }); describe('Tunnel path allowlist', () => { - test('TUNNEL_PATHS is a closed set containing exactly /connect, /command, /sidebar-chat', () => { + test('TUNNEL_PATHS is a closed set containing exactly /connect, /command', () => { const paths = extractSetContents(SERVER_SRC, 'TUNNEL_PATHS'); - expect(paths).toEqual(new Set(['/connect', '/command', '/sidebar-chat'])); + expect(paths).toEqual(new Set(['/connect', '/command'])); }); test('TUNNEL_PATHS does NOT contain bootstrap or admin paths', () => { diff --git a/browse/test/pty-session-lease.test.ts b/browse/test/pty-session-lease.test.ts deleted file mode 100644 index a1053d38ef..0000000000 --- a/browse/test/pty-session-lease.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, test, expect, beforeEach } from 'bun:test'; - -// pty-session-lease registers a sessionId space distinct from the pre-v1.44 -// attach-token space (browse/src/pty-session-cookie.ts). These tests pin -// the validate-first contract that codex outside-voice flagged as critical: -// refreshLease MUST NOT resurrect expired leases, otherwise the 30-min TTL -// stops bounding leaked-token blast radius. - -import { - mintLease, - validateLease, - refreshLease, - revokeLease, - leaseCount, - __resetLeases, -} from '../src/pty-session-lease'; - -beforeEach(() => { - __resetLeases(); -}); - -describe('pty-session-lease: mint/validate/revoke', () => { - test('mintLease returns a fresh non-secret sessionId + future expiresAt', () => { - const a = mintLease(); - const b = mintLease(); - expect(a.sessionId).toBeTruthy(); - expect(b.sessionId).toBeTruthy(); - expect(a.sessionId).not.toBe(b.sessionId); - expect(a.expiresAt).toBeGreaterThan(Date.now()); - // base64url alphabet: characters in [A-Za-z0-9_-]. - expect(a.sessionId).toMatch(/^[A-Za-z0-9_-]+$/); - expect(leaseCount()).toBe(2); - }); - - test('validateLease ok for fresh lease, false for unknown', () => { - const { sessionId } = mintLease(); - const ok = validateLease(sessionId); - expect(ok.ok).toBe(true); - if (ok.ok) expect(ok.expiresAt).toBeGreaterThan(Date.now()); - expect(validateLease('not-a-real-session-id').ok).toBe(false); - expect(validateLease(null).ok).toBe(false); - expect(validateLease(undefined).ok).toBe(false); - }); - - test('revokeLease removes the lease; subsequent validate returns false', () => { - const { sessionId } = mintLease(); - expect(validateLease(sessionId).ok).toBe(true); - revokeLease(sessionId); - expect(validateLease(sessionId).ok).toBe(false); - expect(leaseCount()).toBe(0); - }); - - test('revokeLease tolerates unknown sessionId without throwing', () => { - expect(() => revokeLease('phantom')).not.toThrow(); - expect(() => revokeLease(null)).not.toThrow(); - }); -}); - -describe('pty-session-lease: refresh contract (validate-first)', () => { - test('refreshLease extends expiresAt for a valid lease', () => { - const { sessionId, expiresAt: initial } = mintLease(); - // Sleep micro-tick — Date.now() is ms-grain so a synchronous extend - // may not move the integer. Use a tight async wait instead. - return new Promise((resolve) => { - setTimeout(() => { - const r = refreshLease(sessionId); - expect(r.ok).toBe(true); - if (r.ok) expect(r.expiresAt).toBeGreaterThan(initial); - resolve(); - }, 5); - }); - }); - - test('refreshLease rejects unknown sessionId (validate-first invariant)', () => { - const r = refreshLease('never-minted'); - expect(r.ok).toBe(false); - }); - - test('refreshLease never resurrects an expired lease', async () => { - // Force TTL down to 5ms for this assertion by minting + waiting past expiry. - // Lease internals use Date.now() so the easiest way to expire one is - // to artificially backdate via revoke+remint cycle. Simpler: mint, then - // wait for the registry's own expiry check to trip. - // - // We can't backdate without breaking encapsulation, so this test exercises - // the negative-validate path: minted lease, then prove that refresh after - // explicit revoke still returns ok:false (same as expired-and-pruned). - const { sessionId } = mintLease(); - revokeLease(sessionId); - const r = refreshLease(sessionId); - expect(r.ok).toBe(false); - }); - - test('refreshLease tolerates null / undefined sessionId', () => { - expect(refreshLease(null).ok).toBe(false); - expect(refreshLease(undefined).ok).toBe(false); - }); -}); diff --git a/browse/test/security-sidepanel-dom.test.ts b/browse/test/security-sidepanel-dom.test.ts deleted file mode 100644 index 38f724de92..0000000000 --- a/browse/test/security-sidepanel-dom.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Real-Chromium regression coverage for the sidepanel's current security UI. - * - * The classifier-backed chat queue was removed when the primary surface - * became a terminal PTY. Until classifier status is wired to that surface, - * the honest contract is deliberately negative: - * - * - /health.security.status must not light the hidden SEC shield. - * - retired /sidebar-chat security_event data must not render a banner or - * leak attacker-controlled text into the terminal surface. - * - * Every HTTP, SSE, WebSocket, and beacon primitive is replaced before the - * sidepanel scripts load, so this test never reaches a real browse server. - */ - -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; -import * as fs from 'fs'; -import * as path from 'path'; -import { chromium, type Browser, type Page } from 'playwright'; - -const EXTENSION_DIR = path.resolve(import.meta.dir, '..', '..', 'extension'); -const SIDEPANEL_URL = `file://${EXTENSION_DIR}/sidepanel.html`; - -const CHROMIUM_AVAILABLE = (() => { - try { - const executable = chromium.executablePath(); - return Boolean(executable && fs.existsSync(executable)); - } catch { - return false; - } -})(); - -type Scenario = { - healthSecurity: { - status: 'protected' | 'degraded' | 'inactive'; - layers?: Record; - }; - securityEntries?: unknown[]; -}; - -async function installStubsBeforeLoad(page: Page, scenario: Scenario): Promise { - await page.addInitScript((params: Scenario) => { - const requests: Array<{ url: string; method: string }> = []; - (window as any).__gstackTestRequests = requests; - - (window as any).chrome = { - runtime: { - sendMessage: (_request: unknown, callback?: (value: unknown) => void) => { - // Omit a token so sidepanel.js exercises the direct /health - // bootstrap path whose security payload is under test. - const payload = { connected: true, port: 34567 }; - if (typeof callback === 'function') { - setTimeout(() => callback(payload), 0); - return undefined; - } - return Promise.resolve(payload); - }, - lastError: null, - onMessage: { addListener: () => {} }, - }, - tabs: { - query: (_query: unknown, callback: (tabs: unknown[]) => void) => - setTimeout(() => callback([{ id: 1, url: 'https://example.com' }]), 0), - onActivated: { addListener: () => {} }, - onUpdated: { addListener: () => {} }, - }, - }; - - (window as any).EventSource = class StubEventSource { - static CONNECTING = 0; - static OPEN = 1; - static CLOSED = 2; - readyState = 1; - - constructor(url: string) { - requests.push({ url: String(url), method: 'EVENTSOURCE' }); - } - - addEventListener() {} - close() { this.readyState = 2; } - }; - - (window as any).WebSocket = class StubWebSocket { - static CONNECTING = 0; - static OPEN = 1; - static CLOSING = 2; - static CLOSED = 3; - readyState = 0; - - constructor(url: string) { - requests.push({ url: String(url), method: 'WEBSOCKET' }); - } - - addEventListener() {} - send() {} - close() { this.readyState = 3; } - }; - - Object.defineProperty(navigator, 'sendBeacon', { - configurable: true, - value: (url: string) => { - requests.push({ url: String(url), method: 'BEACON' }); - return true; - }, - }); - - window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - requests.push({ url, method: init?.method ?? 'GET' }); - - if (url.endsWith('/health')) { - return new Response(JSON.stringify({ - status: 'healthy', - token: 'test-token', - AUTH_TOKEN: 'test-token', - mode: 'headed', - agent: { status: 'idle', runningFor: null, queueLength: 0 }, - session: null, - security: params.healthSecurity, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - } - if (url.endsWith('/sse-session')) { - return new Response(null, { status: 204 }); - } - if (url.endsWith('/memory')) { - return new Response(JSON.stringify({ bunServer: { rss: 0 }, tabs: [] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - if (url.endsWith('/pty-session')) { - // Keep the terminal bootstrap deterministic and prevent a WebSocket - // attempt; this test concerns the pre-session terminal surface. - return new Response('terminal disabled in DOM test', { status: 503 }); - } - if (url.includes('/sidebar-chat')) { - return new Response(JSON.stringify({ - entries: params.securityEntries ?? [], - total: (params.securityEntries ?? []).length, - agentStatus: 'idle', - security: params.healthSecurity, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - } - if (url.endsWith('/refs')) { - return new Response(JSON.stringify({ refs: [] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Fail closed inside the stub rather than falling through to the real - // network. Recording the URL above keeps unexpected bootstrap calls - // diagnosable in assertion output. - return new Response(JSON.stringify({ error: 'unstubbed test endpoint' }), { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }); - }; - }, scenario); -} - -async function openStubbedSidepanel( - scenario: Scenario, - assertion: (page: Page) => Promise, -): Promise { - const context = await browser!.newContext(); - try { - const page = await context.newPage(); - await installStubsBeforeLoad(page, scenario); - await page.goto(SIDEPANEL_URL); - await page.waitForFunction(() => - (window as any).gstackAuthToken === 'test-token' && - document.getElementById('footer-dot')?.classList.contains('connected'), - ); - await assertion(page); - } finally { - await context.close(); - } -} - -let browser: Browser | null = null; - -beforeAll(async () => { - if (!CHROMIUM_AVAILABLE) return; - browser = await chromium.launch({ headless: true }); -}, 30_000); - -afterAll(async () => { - if (!browser) return; - try { - await browser.close(); - } catch {} - browser = null; -}); - -describe('sidepanel security DOM', () => { - test.skipIf(!CHROMIUM_AVAILABLE)( - 'protected health metadata does not expose an unwired SEC claim', - async () => { - await openStubbedSidepanel({ - healthSecurity: { - status: 'protected', - layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' }, - }, - }, async (page) => { - const shield = page.locator('#security-shield'); - expect(await shield.count()).toBe(1); - expect(await shield.isVisible()).toBe(false); - expect(await shield.getAttribute('data-status')).toBeNull(); - expect(await shield.getAttribute('aria-label')).toBe('Security status: unknown'); - - const visibleText = await page.locator('body').innerText(); - expect(visibleText).not.toContain('SEC'); - expect(visibleText.toLowerCase()).not.toContain('protected'); - - const requests = await page.evaluate(() => (window as any).__gstackTestRequests); - expect(requests.some((request: { url: string }) => request.url.endsWith('/health'))).toBe(true); - expect(requests.some((request: { url: string }) => request.url.endsWith('/sse-session'))).toBe(true); - }); - }, - 15_000, - ); - - test.skipIf(!CHROMIUM_AVAILABLE)( - 'retired security_event data is neither polled nor rendered into the terminal', - async () => { - const attackerMarker = 'ATTACKER-CONTROLLED-TERMINAL-MARKER'; - const attackerDomain = 'retired-chat.attacker.example'; - await openStubbedSidepanel({ - healthSecurity: { - status: 'protected', - layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' }, - }, - securityEntries: [{ - id: 1, - ts: '2026-04-20T00:00:00Z', - role: 'agent', - type: 'security_event', - verdict: 'block', - reason: attackerMarker, - layer: 'canary', - confidence: 1, - domain: attackerDomain, - }], - }, async (page) => { - // Let immediate connection work and the first memory poll settle; - // neither may reintroduce the retired chat polling path. - await page.waitForTimeout(650); - - const requests = await page.evaluate(() => (window as any).__gstackTestRequests); - expect(requests.some((request: { url: string }) => request.url.includes('/sidebar-chat'))).toBe(false); - expect(requests.some((request: { url: string }) => request.url.endsWith('/memory'))).toBe(true); - expect(requests.some((request: { url: string }) => request.url.startsWith('https://'))).toBe(false); - - expect(await page.locator('#security-banner').count()).toBe(0); - expect(await page.locator('.security-banner').count()).toBe(0); - const terminalText = await page.locator('#tab-terminal').innerText(); - expect(terminalText).not.toContain(attackerMarker); - expect(terminalText).not.toContain(attackerDomain); - expect(await page.locator('#security-shield').isVisible()).toBe(false); - }); - }, - 15_000, - ); -}); diff --git a/browse/test/server-auth.test.ts b/browse/test/server-auth.test.ts index 2469a121b3..24118e68bf 100644 --- a/browse/test/server-auth.test.ts +++ b/browse/test/server-auth.test.ts @@ -314,7 +314,7 @@ describe('Server auth security', () => { // Regression: connect command crashed with "domains is not defined" because // a stray `domains,` variable was in the status fetch body (cli.ts:852). test('connect command status fetch body has no undefined variable references', () => { - const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Terminal agent started'); + const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Connect failed'); // The status fetch should use a clean JSON body expect(connectBlock).toContain("command: 'status'"); // Must NOT contain a bare `domains` reference in the fetch body @@ -341,7 +341,7 @@ describe('Server auth security', () => { // assigned via object-literal syntax (`BROWSE_PARENT_PID: '0'`) // inside the `const serverEnv: Record = { ... }` // declaration. Assert both pieces appear in the connect block. - const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Terminal agent started'); + const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Connect failed'); expect(connectBlock).toContain("const serverEnv"); expect(connectBlock).toContain("BROWSE_PARENT_PID: '0'"); }); diff --git a/browse/test/server-embedder-terminal-port.test.ts b/browse/test/server-embedder-terminal-port.test.ts deleted file mode 100644 index f24ee35101..0000000000 --- a/browse/test/server-embedder-terminal-port.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { describe, test, expect, beforeEach, beforeAll, afterAll } from 'bun:test'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as crypto from 'crypto'; -import { - buildFetchHandler, - __resetShuttingDown, - type ServerConfig, -} from '../src/server'; -import { __resetRegistry } from '../src/token-registry'; -import { BrowserManager } from '../src/browser-manager'; -import { resolveConfig } from '../src/config'; - -// Tests for the v1.41+ ownsTerminalAgent flag. -// -// Embedders (gbrowser phoenix overlay) that run their own PTY server and write -// terminal-port / terminal-internal-token / terminal-agent-pid themselves were -// getting those files clobbered by gstack's shutdown(). The flag (default true) -// gates four side effects (v1.44+): -// 1. identity-based kill of the PID in /terminal-agent-pid -// 2. unlink terminal-port -// 3. unlink terminal-internal-token -// 4. unlink terminal-agent-pid -// False = embedder owns them, gstack stays hands-off. -// -// Pre-v1.44 used `pkill -f terminal-agent\.ts` which matched sibling gstack -// sessions on the same host — see browse/src/terminal-agent-control.ts header. -// -// CRITICAL: each test stubs process.exit (so shutdown's exit doesn't kill -// the test runner). The PID in the test agent-record is a guaranteed-dead -// PID (1 = init / launchd — exists but cannot be killed by an unprivileged -// process, so safeKill returns ESRCH-equivalent without affecting anything). -// Use isProcessAlive's false branch by also testing with a PID that does -// not exist (negative PID rejected by the OS). - -const stateDir = resolveConfig().stateDir; -const PORT_FILE = path.join(stateDir, 'terminal-port'); -const TOKEN_FILE = path.join(stateDir, 'terminal-internal-token'); -const AGENT_RECORD_FILE = path.join(stateDir, 'terminal-agent-pid'); -const SENTINEL_PORT = 'sentinel-port-65432'; -const SENTINEL_TOKEN = 'sentinel-token-abcdef1234567890'; -// PID 2^31-1 is the Linux PID_MAX_LIMIT; macOS uses 99998. Either way, no -// real process will ever hold this PID on a developer machine. isProcessAlive -// returns false → killAgentByRecord no-ops without sending any signal. -const SENTINEL_DEAD_PID = 2147483646; - -function makeMinimalConfig(overrides: Partial = {}): ServerConfig { - const token = 'embedder-test-' + crypto.randomBytes(16).toString('hex'); - return { - authToken: token, - browsePort: 34568, - idleTimeoutMs: 1_800_000, - config: resolveConfig(), - browserManager: new BrowserManager(), - startTime: Date.now(), - ...overrides, - }; -} - -function writeSentinels(): void { - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(PORT_FILE, SENTINEL_PORT); - fs.writeFileSync(TOKEN_FILE, SENTINEL_TOKEN); - fs.writeFileSync( - AGENT_RECORD_FILE, - JSON.stringify({ pid: SENTINEL_DEAD_PID, gen: 'sentinel-gen', startedAt: Date.now() }), - ); -} - -function readIfExists(p: string): string | null { - try { return fs.readFileSync(p, 'utf-8'); } catch { return null; } -} - -/** - * Stubs process.exit so shutdown()'s process.exit(0) throws an __exit:N - * marker the test can swallow instead of killing the runner. Also stubs - * process.kill so an accidental kill (regression in killAgentByRecord - * that bypassed isProcessAlive) cannot reach a real PID on the developer - * machine. Returns the captured kill calls so tests can assert kill - * scope. - */ -async function withStubs( - cb: (killCalls: Array<[number, NodeJS.Signals | number]>) => Promise -): Promise> { - const origExit = process.exit; - const origKill = process.kill; - const killCalls: Array<[number, NodeJS.Signals | number]> = []; - (process as any).exit = ((code: number) => { - throw new Error(`__exit:${code}`); - }) as any; - (process as any).kill = ((pid: number, signal: NodeJS.Signals | number) => { - killCalls.push([pid, signal ?? 'SIGTERM']); - // signal 0 is a liveness probe — keep the existing 'process is dead' - // semantics so isProcessAlive(SENTINEL_DEAD_PID) returns false. - if (signal === 0) { - const err: any = new Error('No such process'); - err.code = 'ESRCH'; - throw err; - } - return true; - }) as any; - try { - await cb(killCalls); - } finally { - (process as any).exit = origExit; - (process as any).kill = origKill; - } - return killCalls; -} - -async function runShutdown(handle: { shutdown: (code?: number) => Promise }): Promise { - try { - await handle.shutdown(0); - } catch (err: any) { - if (typeof err?.message !== 'string' || !err.message.startsWith('__exit:')) throw err; - } -} - -// Filter out the signal=0 liveness probes; only count actual termination signals. -function terminationCalls( - calls: Array<[number, NodeJS.Signals | number]>, -): Array<[number, NodeJS.Signals | number]> { - return calls.filter(([, sig]) => sig !== 0); -} - -describe('buildFetchHandler ownsTerminalAgent gate', () => { - // shutdown() reads `path.dirname(config.stateFile)` from module-level config - // (composition gap — see TODOS T9). So unlinks target the real state dir, - // not a per-test temp dir. If a real gstack daemon is running on this host, - // its terminal-port + terminal-internal-token + terminal-agent-pid live - // where this test writes. Save + restore real-daemon file contents around - // the whole suite so the test never clobbers a developer's running session. - let realPortBackup: string | null = null; - let realTokenBackup: string | null = null; - let realAgentRecordBackup: string | null = null; - - beforeAll(() => { - realPortBackup = readIfExists(PORT_FILE); - realTokenBackup = readIfExists(TOKEN_FILE); - realAgentRecordBackup = readIfExists(AGENT_RECORD_FILE); - }); - - afterAll(() => { - if (realPortBackup !== null) { - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(PORT_FILE, realPortBackup); - } else { - try { fs.unlinkSync(PORT_FILE); } catch {} - } - if (realTokenBackup !== null) { - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(TOKEN_FILE, realTokenBackup); - } else { - try { fs.unlinkSync(TOKEN_FILE); } catch {} - } - if (realAgentRecordBackup !== null) { - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(AGENT_RECORD_FILE, realAgentRecordBackup); - } else { - try { fs.unlinkSync(AGENT_RECORD_FILE); } catch {} - } - }); - - beforeEach(() => { - __resetRegistry(); - __resetShuttingDown(); - // Clean any leftover sentinels from a prior failed run so the "preserved" - // assertion can't pass spuriously off a stale file. - try { fs.unlinkSync(PORT_FILE); } catch {} - try { fs.unlinkSync(TOKEN_FILE); } catch {} - try { fs.unlinkSync(AGENT_RECORD_FILE); } catch {} - }); - - test('1. ownsTerminalAgent:false preserves all three files and sends no signal', async () => { - writeSentinels(); - const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: false })); - const calls = await withStubs(async () => { - await runShutdown(handle); - }); - expect(readIfExists(PORT_FILE)).toBe(SENTINEL_PORT); - expect(readIfExists(TOKEN_FILE)).toBe(SENTINEL_TOKEN); - expect(readIfExists(AGENT_RECORD_FILE)).not.toBeNull(); - expect(terminationCalls(calls).length).toBe(0); - }); - - test('2. ownsTerminalAgent:true deletes all three files; identity-based kill probes the recorded PID', async () => { - writeSentinels(); - const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: true })); - const calls = await withStubs(async () => { - await runShutdown(handle); - }); - expect(readIfExists(PORT_FILE)).toBeNull(); - expect(readIfExists(TOKEN_FILE)).toBeNull(); - expect(readIfExists(AGENT_RECORD_FILE)).toBeNull(); - // isProcessAlive sends signal 0; PID is the sentinel-dead PID, so the - // probe returns false and no SIGTERM is sent. - const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0); - expect(probes.length).toBeGreaterThan(0); - expect(terminationCalls(calls).length).toBe(0); - }); - - test('3. ownsTerminalAgent unset defaults to true (deletes all three; probes recorded PID)', async () => { - writeSentinels(); - // Note: no ownsTerminalAgent in the overrides — uses the `?? true` default. - const handle = buildFetchHandler(makeMinimalConfig()); - const calls = await withStubs(async () => { - await runShutdown(handle); - }); - expect(readIfExists(PORT_FILE)).toBeNull(); - expect(readIfExists(TOKEN_FILE)).toBeNull(); - expect(readIfExists(AGENT_RECORD_FILE)).toBeNull(); - const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0); - expect(probes.length).toBeGreaterThan(0); - }); - - test('4. CLI start() call site passes ownsTerminalAgent: true literally (static grep)', () => { - // Resolves browse/src/server.ts relative to this test file so the test - // works regardless of cwd. import.meta.url is the test file's URL. - const serverTsPath = path.resolve( - new URL(import.meta.url).pathname, - '..', - '..', - 'src', - 'server.ts', - ); - const source = fs.readFileSync(serverTsPath, 'utf-8'); - // Match the call site inside start()'s buildFetchHandler({...}) literal. - // The pattern looks for the trailing comma and trailing context so the - // match cannot be satisfied by the JSDoc reference earlier in the file. - expect(source).toMatch(/ownsTerminalAgent:\s*true,\s*\/\/\s*CLI spawns terminal-agent\.ts/); - }); -}); diff --git a/browse/test/server-pty-lease-routes.test.ts b/browse/test/server-pty-lease-routes.test.ts deleted file mode 100644 index 2c12618830..0000000000 --- a/browse/test/server-pty-lease-routes.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, test, expect } from 'bun:test'; -import * as fs from 'fs'; -import * as path from 'path'; - -// Server-side route shape for the v1.44 lease + restart + dispose + -// lease-refresh wiring. Live route exercises require the terminal-agent -// loopback to be live (e2e-tier); these static-grep tripwires pin the -// load-bearing protocol invariants. - -const SERVER_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'server.ts'); - -describe('server: PTY lease routes (v1.44+ Commit 2)', () => { - test('1. /pty-session returns the 4-tuple shape (sessionId, attachToken, leaseExpiresAt)', () => { - const src = fs.readFileSync(SERVER_TS, 'utf-8'); - const block = sliceBetween(src, "url.pathname === '/pty-session' &&", "url.pathname === '/pty-session/reattach'"); - expect(block).toContain('mintLease()'); - expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)'); - expect(block).toContain('sessionId: lease.sessionId'); - expect(block).toContain('attachToken: minted.token'); - expect(block).toContain('leaseExpiresAt: lease.expiresAt'); - // Backward compat: legacy ptySessionToken alias preserved for one release. - expect(block).toContain('ptySessionToken: minted.token'); - }); - - test('2. /pty-session/reattach validates lease + mints fresh attachToken', () => { - const src = fs.readFileSync(SERVER_TS, 'utf-8'); - const block = sliceBetween(src, "url.pathname === '/pty-session/reattach'", "url.pathname === '/pty-restart'"); - // Validate-first: rejects unknown/expired sessionId with 410 Gone so - // the client knows to fall back to a fresh /pty-session. - expect(block).toContain('validateLease(sessionId)'); - expect(block).toContain('status: 410'); - // Mint fresh token bound to SAME sessionId. - expect(block).toContain('grantPtyToken(minted.token, sessionId!)'); - }); - - test('3. /pty-restart is one transaction — dispose + revoke + fresh mint', () => { - const src = fs.readFileSync(SERVER_TS, 'utf-8'); - const block = sliceBetween(src, "url.pathname === '/pty-restart'", "url.pathname === '/pty-dispose'"); - // Disposes old session (best-effort — missing sessionId is non-fatal). - expect(block).toContain('restartPtySession(oldSessionId)'); - expect(block).toContain('revokeLease(oldSessionId)'); - // Then mints fresh sessionId + lease + attachToken in the same handler. - expect(block).toContain('mintLease()'); - expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)'); - // Returns the same 4-tuple shape so the client doesn't need a - // separate /pty-session round-trip. - expect(block).toContain('attachToken: minted.token'); - expect(block).toContain('leaseExpiresAt: lease.expiresAt'); - }); - - test('4. /pty-dispose accepts body-token (sendBeacon-compatible)', () => { - const src = fs.readFileSync(SERVER_TS, 'utf-8'); - const block = sliceBetween(src, "url.pathname === '/pty-dispose'", "url.pathname === '/internal/lease-refresh'"); - // sendBeacon can't set custom headers, so the route MUST accept the - // auth token in the request body. Otherwise pagehide cleanup fails - // silently every time the user closes the browser. - expect(block).toContain('body?.authToken'); - expect(block).toContain('authedByBody'); - // Both auth paths must validate against authToken — never just trust - // a body-supplied token without the equality check. - expect(block).toContain('authTokenFromBody === authToken'); - }); - - test('5. /internal/lease-refresh resets the daemon idle timer (T6)', () => { - const src = fs.readFileSync(SERVER_TS, 'utf-8'); - const block = sliceBetween(src, "url.pathname === '/internal/lease-refresh'", '─── /pty-inject-scan'); - expect(block).toContain('refreshLease(sessionId)'); - expect(block).toContain('resetIdleTimer()'); - // Refresh failure (unknown / expired) MUST 410, not 200, so the - // agent knows to close the WS and force a clean re-auth. - expect(block).toContain('status: 410'); - }); - - test('6. grantPtyToken loopback carries sessionId binding', () => { - const src = fs.readFileSync(SERVER_TS, 'utf-8'); - expect(src).toMatch(/grantPtyToken\(token: string, sessionId\?: string\)/); - expect(src).toContain('sessionId ? { token, sessionId } : { token }'); - }); - - test('7. restartPtySession helper exists and POSTs the agent /internal/restart', () => { - const src = fs.readFileSync(SERVER_TS, 'utf-8'); - expect(src).toMatch(/async function restartPtySession\(sessionId: string\)/); - expect(src).toContain('/internal/restart'); - expect(src).toContain('JSON.stringify({ sessionId })'); - }); -}); - -function sliceBetween(source: string, start: string, end: string): string { - const i = source.indexOf(start); - if (i === -1) throw new Error(`marker not found: ${start}`); - const j = source.indexOf(end, i + start.length); - if (j === -1) throw new Error(`end marker not found: ${end}`); - return source.slice(i, j); -} diff --git a/browse/test/sidebar-integration.test.ts b/browse/test/sidebar-integration.test.ts deleted file mode 100644 index 534609734e..0000000000 --- a/browse/test/sidebar-integration.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * HTTP regression for the terminal-first sidepanel architecture. - * - * The legacy one-shot sidebar-agent/chat queue was removed in v1.44. These - * routes must stay unavailable: silently reviving one would recreate a second - * agent lifecycle and its retired prompt/security surface. Current terminal, - * activity, and browser routes have their own focused integration suites. - */ - -import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; -import { spawn, type Subprocess } from 'bun'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -let serverProc: Subprocess | null = null; -let serverPort = 0; -let authToken = ''; -let tmpDir = ''; -let stateFile = ''; -let retiredQueueFile = ''; - -async function api(pathname: string, opts: RequestInit & { noAuth?: boolean } = {}): Promise { - const { noAuth, ...fetchOpts } = opts; - const headers: Record = { - 'Content-Type': 'application/json', - ...(fetchOpts.headers as Record || {}), - }; - if (!noAuth && !headers.Authorization && authToken) { - headers.Authorization = `Bearer ${authToken}`; - } - return fetch(`http://127.0.0.1:${serverPort}${pathname}`, { ...fetchOpts, headers }); -} - -beforeAll(async () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-retired-routes-')); - stateFile = path.join(tmpDir, 'browse.json'); - retiredQueueFile = path.join(tmpDir, 'sidebar-queue.jsonl'); - - const serverScript = path.resolve(import.meta.dir, '..', 'src', 'server.ts'); - serverProc = spawn(['bun', 'run', serverScript], { - env: { - ...process.env, - BROWSE_STATE_FILE: stateFile, - BROWSE_HEADLESS_SKIP: '1', - BROWSE_PORT: '0', - SIDEBAR_QUEUE_PATH: retiredQueueFile, - BROWSE_IDLE_TIMEOUT: '300', - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - if (fs.existsSync(stateFile)) { - try { - const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); - if (state.port && state.token) { - serverPort = state.port; - authToken = state.token; - break; - } - } catch {} - } - await Bun.sleep(100); - } - if (!serverPort) throw new Error('Server did not start in time'); -}, 20_000); - -afterAll(() => { - if (serverProc) { - try { serverProc.kill(); } catch {} - } - try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} -}); - -const RETIRED_ROUTES: Array<[string, string]> = [ - ['POST', '/sidebar-command'], - ['POST', '/sidebar-agent/event'], - ['POST', '/sidebar-agent/kill'], - ['GET', '/sidebar-session'], - ['POST', '/sidebar-session/new'], - ['GET', '/sidebar-chat?after=0'], - ['POST', '/sidebar-chat/clear'], -]; - -describe('retired sidebar-agent HTTP surface', () => { - test('still applies authentication before disclosing route availability', async () => { - const response = await api('/sidebar-command', { - method: 'POST', - noAuth: true, - body: JSON.stringify({ message: 'test' }), - }); - expect(response.status).toBe(401); - }); - - test('every retired route is absent for an authenticated caller', async () => { - for (const [method, route] of RETIRED_ROUTES) { - const response = await api(route, { - method, - body: method === 'GET' ? undefined : JSON.stringify({ message: 'test', type: 'text' }), - }); - expect(response.status).toBe(404); - } - }); - - test('probing retired routes never creates the old queue file', async () => { - expect(fs.existsSync(retiredQueueFile)).toBe(false); - await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ message: 'must not queue' }), - }); - expect(fs.existsSync(retiredQueueFile)).toBe(false); - }); - - test('the current authenticated health surface remains available', async () => { - const response = await api('/health'); - expect(response.status).toBe(200); - const payload = await response.json() as { status?: string }; - expect(['healthy', 'unhealthy']).toContain(payload.status); - }); -}); diff --git a/browse/test/sidebar-security.test.ts b/browse/test/sidebar-security.test.ts deleted file mode 100644 index f371317bb7..0000000000 --- a/browse/test/sidebar-security.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Current terminal-sidepanel security boundary. - * - * Detailed PTY lifecycle behavior has dedicated tests. These source contracts - * instead pin the cross-process handoff: the extension trades the daemon root - * token for a session-scoped attach token, and only the loopback terminal agent - * accepts that token from a Chrome extension origin. - */ - -import { describe, test, expect } from 'bun:test'; -import * as fs from 'fs'; -import * as path from 'path'; - -const ROOT = path.resolve(import.meta.dir, '..', '..'); -const TERMINAL_AGENT_PATH = path.join(ROOT, 'browse', 'src', 'terminal-agent.ts'); -const SERVER_PATH = path.join(ROOT, 'browse', 'src', 'server.ts'); -const LEGACY_AGENT_PATH = path.join(ROOT, 'browse', 'src', 'sidebar-agent.ts'); -const TERMINAL_CLIENT_PATH = path.join(ROOT, 'extension', 'sidepanel-terminal.js'); -const SIDEPANEL_PATH = path.join(ROOT, 'extension', 'sidepanel.js'); -const BACKGROUND_PATH = path.join(ROOT, 'extension', 'background.js'); - -const TERMINAL_AGENT_SRC = fs.readFileSync(TERMINAL_AGENT_PATH, 'utf8'); -const SERVER_SRC = fs.readFileSync(SERVER_PATH, 'utf8'); -const TERMINAL_CLIENT_SRC = fs.readFileSync(TERMINAL_CLIENT_PATH, 'utf8'); -const SIDEPANEL_SRC = fs.readFileSync(SIDEPANEL_PATH, 'utf8'); -const BACKGROUND_SRC = fs.readFileSync(BACKGROUND_PATH, 'utf8'); - -function sliceBetween(source: string, startMarker: string, endMarker: string): string { - const start = source.indexOf(startMarker); - if (start === -1) throw new Error(`Missing source marker: ${startMarker}`); - const end = source.indexOf(endMarker, start + startMarker.length); - if (end === -1) throw new Error(`Missing source marker: ${endMarker}`); - return source.slice(start, end); -} - -describe('terminal sidepanel security boundary', () => { - test('PTY transport stays on loopback and sends attach auth outside the URL', () => { - expect(TERMINAL_AGENT_SRC).toContain("hostname: '127.0.0.1'"); - expect(TERMINAL_AGENT_SRC).not.toContain("hostname: '0.0.0.0'"); - - const socketCalls = [...TERMINAL_CLIENT_SRC.matchAll(/new WebSocket\(([\s\S]*?)\);/g)] - .map((match) => match[1]); - expect(socketCalls.length).toBeGreaterThan(0); - for (const call of socketCalls) { - expect(call).toContain('ws://127.0.0.1:${terminalPort}/ws'); - expect(call).toContain('gstack-pty.${'); - expect(call).not.toContain('/ws?'); - expect(call).not.toContain('authToken'); - } - }); - - test('WebSocket upgrade requires extension Origin plus an in-memory session token', () => { - expect(TERMINAL_AGENT_SRC).toContain('const validTokens = new Map()'); - const wsRoute = sliceBetween( - TERMINAL_AGENT_SRC, - "if (url.pathname === '/ws')", - "return new Response('not found'", - ); - - const originGate = wsRoute.indexOf("origin.startsWith('chrome-extension://')"); - const tokenGate = wsRoute.indexOf('validTokens.has(candidate)'); - const upgrade = wsRoute.indexOf('server.upgrade(req'); - expect(originGate).toBeGreaterThan(-1); - expect(tokenGate).toBeGreaterThan(originGate); - expect(upgrade).toBeGreaterThan(tokenGate); - expect(wsRoute).toContain('forbidden origin'); - expect(wsRoute).toContain("req.headers.get('sec-websocket-protocol')"); - expect(wsRoute).not.toContain("searchParams.get('token')"); - }); - - test('/pty-session authenticates the daemon token then mints a session-scoped attach', () => { - const route = sliceBetween( - SERVER_SRC, - "if (url.pathname === '/pty-session' && req.method === 'POST')", - "if (url.pathname === '/pty-session/reattach'", - ); - expect(route.indexOf('validateAuth(req)')).toBeLessThan(route.indexOf('mintLease()')); - expect(route).toContain('grantPtyToken(minted.token, lease.sessionId)'); - expect(route).toContain('sessionId: lease.sessionId'); - expect(route).toContain('attachToken: minted.token'); - - const clientMint = sliceBetween( - TERMINAL_CLIENT_SRC, - 'async function mintSession()', - 'function startReattachLoop', - ); - expect(clientMint).toContain('/pty-session`'); - expect(clientMint).toContain("'Authorization': `Bearer ${token}`"); - expect(clientMint).not.toContain('?token='); - }); - - test('/pty-dispose authenticates and tears down only the named session', () => { - const route = sliceBetween( - SERVER_SRC, - "if (url.pathname === '/pty-dispose'", - "if (url.pathname === '/internal/lease-refresh'", - ); - expect(route).toContain('authTokenFromBody === authToken'); - expect(route).toContain("body?.sessionId === 'string'"); - expect(route).toContain('restartPtySession(sessionId)'); - expect(route).toContain('revokeLease(sessionId)'); - - const pagehide = SIDEPANEL_SRC.slice(SIDEPANEL_SRC.indexOf("addEventListener('pagehide'")); - expect(TERMINAL_CLIENT_SRC).toContain('window.gstackPtySession = currentSessionId'); - expect(pagehide).toContain('JSON.stringify({ sessionId, authToken })'); - expect(pagehide).toContain('/pty-dispose`'); - expect(pagehide).not.toContain('/pty-dispose?'); - }); - - test('background token bootstrap rejects foreign and content-script requesters', () => { - const listener = sliceBetween( - BACKGROUND_SRC, - 'chrome.runtime.onMessage.addListener((msg, sender, sendResponse)', - "if (msg.type === 'fetchRefs')", - ); - expect(listener).toContain('sender.id !== chrome.runtime.id'); - - const getToken = listener.slice(listener.indexOf("if (msg.type === 'getToken')")); - expect(getToken).toContain('if (sender.tab)'); - expect(getToken).toContain('sendResponse({ token: null })'); - expect(getToken).toContain('sendResponse({ token: authToken })'); - }); - - test('interactive prompt path replaces the retired sidebar agent and routes', () => { - expect(fs.existsSync(LEGACY_AGENT_PATH)).toBe(false); - expect(SERVER_SRC).not.toMatch(/url\.pathname\s*===\s*['"]\/sidebar-/); - expect(SERVER_SRC).not.toMatch(/url\.pathname\.startsWith\(\s*['"]\/sidebar-/); - expect(SERVER_SRC).toContain('chatEnabled: false'); - - const spawn = sliceBetween(TERMINAL_AGENT_SRC, 'function spawnClaude', '/** Cleanup a PTY session'); - expect(spawn).toContain("[claudePath, '--append-system-prompt', tabHint]"); - expect(spawn).not.toMatch(/claudePath,\s*['"](?:-p|--print)['"]/); - }); -}); diff --git a/browse/test/sidebar-tabs.test.ts b/browse/test/sidebar-tabs.test.ts deleted file mode 100644 index 682b0d9626..0000000000 --- a/browse/test/sidebar-tabs.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Regression: sidebar layout invariants after the chat-tab rip. - * - * The Chrome side panel used to host two surfaces: Chat (one-shot - * `claude -p` queue) and Terminal (interactive PTY). Chat was ripped - * once the PTY proved out — sidebar-agent.ts is gone, the chat queue - * endpoints are gone, and the primary-tab nav (Terminal | Chat) is - * gone. Terminal is now the sole primary surface. - * - * This file locks the load-bearing invariants of that layout so a - * future refactor can't silently re-introduce the old surface or break - * the new one. - */ - -import { describe, test, expect } from 'bun:test'; -import * as fs from 'fs'; -import * as path from 'path'; - -const HTML = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.html'), 'utf-8'); -const JS = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.js'), 'utf-8'); -const TERM_JS = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel-terminal.js'), 'utf-8'); -const MANIFEST = JSON.parse(fs.readFileSync(path.join(import.meta.dir, '../../extension/manifest.json'), 'utf-8')); - -describe('sidebar: chat tab + nav are removed, Terminal is sole primary surface', () => { - test('No primary-tab nav element exists', () => { - expect(HTML).not.toContain('class="primary-tabs"'); - expect(HTML).not.toContain('data-pane="chat"'); - expect(HTML).not.toContain('data-pane="terminal"'); - }); - - test('No
pane', () => { - expect(HTML).not.toMatch(/]*id="tab-chat"/); - expect(HTML).not.toContain('id="chat-messages"'); - expect(HTML).not.toContain('id="chat-loading"'); - expect(HTML).not.toContain('id="chat-welcome"'); - }); - - test('No chat input / send button / experimental banner', () => { - expect(HTML).not.toContain('class="command-bar"'); - expect(HTML).not.toContain('id="command-input"'); - expect(HTML).not.toContain('id="send-btn"'); - expect(HTML).not.toContain('id="stop-agent-btn"'); - expect(HTML).not.toContain('id="experimental-banner"'); - }); - - test('No clear-chat button in footer', () => { - expect(HTML).not.toContain('id="clear-chat"'); - }); - - test('Terminal pane is .active by default and has the toolbar', () => { - expect(HTML).toMatch(/]*id="tab-terminal"[^>]*class="tab-content active"/); - expect(HTML).toContain('id="terminal-toolbar"'); - expect(HTML).toContain('id="terminal-restart-now"'); - }); - - test('Quick-actions buttons (Cleanup / Screenshot / Cookies) survive in the terminal toolbar', () => { - // Garry explicitly wanted these kept after the chat rip — they drive - // browser actions, not chat. - expect(HTML).toContain('id="chat-cleanup-btn"'); - expect(HTML).toContain('id="chat-screenshot-btn"'); - expect(HTML).toContain('id="chat-cookies-btn"'); - // They live inside the terminal toolbar now (siblings of the Restart - // button), not as a separate strip below all panes. - const toolbarStart = HTML.indexOf('id="terminal-toolbar"'); - const toolbarEnd = HTML.indexOf('', toolbarStart); - const toolbarBlock = HTML.slice(toolbarStart, toolbarEnd + 6); - expect(toolbarBlock).toContain('id="chat-cleanup-btn"'); - expect(toolbarBlock).toContain('id="chat-screenshot-btn"'); - expect(toolbarBlock).toContain('id="chat-cookies-btn"'); - }); -}); - -describe('sidepanel.js: chat helpers ripped, terminal-injection helper survives', () => { - test('No primary-tab click handler', () => { - expect(JS).not.toContain("querySelectorAll('.primary-tab')"); - expect(JS).not.toContain('activePrimaryPaneId'); - }); - - test('No chat polling, sendMessage, sendChat, stopAgent, or pollTabs', () => { - expect(JS).not.toContain('chatPollInterval'); - expect(JS).not.toContain('function sendMessage'); - expect(JS).not.toContain('function pollChat'); - expect(JS).not.toContain('function pollTabs'); - expect(JS).not.toContain('function switchChatTab'); - expect(JS).not.toContain('function stopAgent'); - expect(JS).not.toContain('function applyChatEnabled'); - expect(JS).not.toContain('function showSecurityBanner'); - }); - - test('Cleanup runs through the live PTY (no /sidebar-command POST)', () => { - // The new Cleanup handler injects the prompt straight into claude's - // PTY via gstackInjectToTerminal. The dead code path was a POST to - // /sidebar-command which kicked off a fresh claude -p subprocess. - const cleanup = JS.slice(JS.indexOf('async function runCleanup')); - expect(cleanup).toContain('window.gstackInjectToTerminal'); - expect(cleanup).not.toContain('/sidebar-command'); - expect(cleanup).not.toContain('addChatEntry'); - }); - - test('Inspector "Send to Code" routes through the live PTY', () => { - const sendBtn = JS.slice(JS.indexOf('inspectorSendBtn.addEventListener')); - expect(sendBtn).toContain('window.gstackInjectToTerminal'); - expect(sendBtn).not.toContain("type: 'sidebar-command'"); - }); - - test('updateConnection no longer kicks off chat / tab polling', () => { - const update = JS.slice(JS.indexOf('function updateConnection'), JS.indexOf('function updateConnection') + 1500); - expect(update).not.toContain('chatPollInterval'); - expect(update).not.toContain('tabPollInterval'); - expect(update).not.toContain('pollChat'); - expect(update).not.toContain('pollTabs'); - // BUT must still expose the bootstrap globals for sidepanel-terminal.js. - expect(update).toContain('window.gstackServerPort'); - expect(update).toContain('window.gstackAuthToken'); - }); -}); - -describe('sidepanel-terminal.js: eager auto-connect + injection API', () => { - test('Exposes window.gstackInjectToTerminal for cross-pane use', () => { - expect(TERM_JS).toContain('window.gstackInjectToTerminal'); - // Returns false when no live session, true when bytes go out. - const inject = TERM_JS.slice(TERM_JS.indexOf('window.gstackInjectToTerminal')); - expect(inject).toContain('return false'); - expect(inject).toContain('return true'); - expect(inject).toContain('ws.readyState !== WebSocket.OPEN'); - }); - - test('Auto-connects on init (no keypress required)', () => { - expect(TERM_JS).not.toContain('function onAnyKey'); - expect(TERM_JS).not.toContain("addEventListener('keydown'"); - expect(TERM_JS).toContain('function tryAutoConnect'); - }); - - test('Repaint hook fires when Terminal pane becomes visible', () => { - // The chat-tab rip removed gstack:primary-tab-changed; we use a - // MutationObserver on #tab-terminal's class attr instead. The - // observer must call repaintIfLive when the .active class returns. - expect(TERM_JS).toContain('MutationObserver'); - expect(TERM_JS).toContain("attributeFilter: ['class']"); - expect(TERM_JS).toContain('repaintIfLive'); - const repaint = TERM_JS.slice(TERM_JS.indexOf('function repaintIfLive')); - expect(repaint).toContain('fitAddon && fitAddon.fit()'); - expect(repaint).toContain('term.refresh'); - expect(repaint).toContain("type: 'resize'"); - }); - - test('No auto-reconnect on close (Restart is user-initiated)', () => { - const closeOnly = TERM_JS.slice( - TERM_JS.indexOf("ws.addEventListener('close'"), - TERM_JS.indexOf("ws.addEventListener('error'"), - ); - expect(closeOnly).not.toContain('setTimeout'); - expect(closeOnly).not.toContain('tryAutoConnect'); - expect(closeOnly).not.toContain('connect()'); - }); - - test('forceRestart uses the session-scoped restart transaction and resets local state', () => { - expect(TERM_JS).toContain('function forceRestart'); - const fn = TERM_JS.slice(TERM_JS.indexOf('function forceRestart')); - expect(fn).toContain("ws && ws.close(4001, 'intentional-restart')"); - expect(fn).toContain('term.dispose()'); - expect(fn).toContain('STATE.IDLE'); - expect(fn).toContain('/pty-restart'); - expect(fn).toContain('priorSessionId'); - expect(fn).toContain('tryAutoConnect()'); - }); - - test('Both restart buttons (mid-session and ENDED) call forceRestart', () => { - expect(TERM_JS).toContain("els.restart?.addEventListener('click', forceRestart)"); - expect(TERM_JS).toContain("els.restartNow?.addEventListener('click', forceRestart)"); - }); -}); - -describe('server.ts: chat / sidebar-agent endpoints are gone', () => { - const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8'); - - test('No /sidebar-command, /sidebar-chat, /sidebar-agent/* routes', () => { - expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-command['"]/); - expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-chat['"]/); - expect(SERVER_SRC).not.toMatch(/url\.pathname\.startsWith\(['"]\/sidebar-agent\//); - expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-agent\/event['"]/); - expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-tabs['"]/); - expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-session['"]/); - }); - - test('No chat-related state declarations or helpers', () => { - // Allow the symbol names inside the rip-marker comments — but no - // `let`, `const`, `function`, or `interface` declarations of them. - expect(SERVER_SRC).not.toMatch(/^let agentProcess/m); - expect(SERVER_SRC).not.toMatch(/^let agentStatus/m); - expect(SERVER_SRC).not.toMatch(/^let messageQueue/m); - expect(SERVER_SRC).not.toMatch(/^let sidebarSession/m); - expect(SERVER_SRC).not.toMatch(/^const tabAgents/m); - expect(SERVER_SRC).not.toMatch(/^function pickSidebarModel/m); - expect(SERVER_SRC).not.toMatch(/^function processAgentEvent/m); - expect(SERVER_SRC).not.toMatch(/^function killAgent/m); - expect(SERVER_SRC).not.toMatch(/^function addChatEntry/m); - expect(SERVER_SRC).not.toMatch(/^interface ChatEntry/m); - expect(SERVER_SRC).not.toMatch(/^interface SidebarSession/m); - }); - - test('/health no longer surfaces agentStatus or messageQueue length', () => { - const health = SERVER_SRC.slice(SERVER_SRC.indexOf("url.pathname === '/health'")); - const slice = health.slice(0, 2000); - expect(slice).not.toContain('agentStatus'); - expect(slice).not.toContain('messageQueue'); - expect(slice).not.toContain('agentStartTime'); - // chatEnabled is hardcoded false now (older clients still see the field). - expect(slice).toMatch(/chatEnabled:\s*false/); - // terminalPort survives. - expect(slice).toContain('terminalPort'); - }); -}); - -describe('cli.ts: sidebar-agent is no longer spawned', () => { - const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8'); - - test('No Bun.spawn of sidebar-agent.ts', () => { - expect(CLI_SRC).not.toMatch(/Bun\.spawn\(\s*\['bun',\s*'run',\s*\w*[Aa]gent[Ss]cript\][\s\S]{0,300}sidebar-agent/); - // The variable name `agentScript` was for sidebar-agent. After the - // rip there's only termAgentScript. Allow comments to mention the - // history but not active spawn calls. - expect(CLI_SRC).not.toMatch(/^\s*let agentScript = path\.resolve/m); - }); - - test('Terminal-agent spawn survives', () => { - expect(CLI_SRC).toContain("import { spawnTerminalAgent } from './terminal-agent-control'"); - expect(CLI_SRC).toMatch(/spawnTerminalAgent\(\{[\s\S]*?stateFile:[\s\S]*?serverPort:[\s\S]*?cwd:/); - }); -}); - -describe('files: sidebar-agent.ts and its tests are deleted', () => { - test('browse/src/sidebar-agent.ts is gone', () => { - expect(fs.existsSync(path.join(import.meta.dir, '../src/sidebar-agent.ts'))).toBe(false); - }); - - test('sidebar-agent test files are gone', () => { - expect(fs.existsSync(path.join(import.meta.dir, 'sidebar-agent.test.ts'))).toBe(false); - expect(fs.existsSync(path.join(import.meta.dir, 'sidebar-agent-roundtrip.test.ts'))).toBe(false); - }); -}); - -describe('manifest: ws permission + xterm-safe CSP', () => { - test('host_permissions covers ws localhost', () => { - expect(MANIFEST.host_permissions).toContain('ws://127.0.0.1:*/'); - }); - - test('host_permissions still covers http localhost', () => { - expect(MANIFEST.host_permissions).toContain('http://127.0.0.1:*/'); - }); - - test('manifest does NOT add unsafe-eval to extension_pages CSP', () => { - const csp = MANIFEST.content_security_policy; - if (csp && csp.extension_pages) { - expect(csp.extension_pages).not.toContain('unsafe-eval'); - } - }); -}); - -describe('manifest: live tab awareness needs "tabs" permission', () => { - // Without "tabs", chrome.tabs.query() returns tab objects with undefined - // url/title for any site outside host_permissions (e.g., everything except - // 127.0.0.1). snapshotTabs() then writes empty strings into tabs.json and - // active-tab.json silently skips the write — the sidebar agent loses track - // of what page the user is on. activeTab is too narrow (only after a user - // gesture on the extension action) for background polling. - test('permissions includes "tabs"', () => { - expect(MANIFEST.permissions).toContain('tabs'); - }); -}); diff --git a/browse/test/sidebar-unit.test.ts b/browse/test/sidebar-unit.test.ts deleted file mode 100644 index 3c0459a042..0000000000 --- a/browse/test/sidebar-unit.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Layer 1: Unit tests for sidebar utilities. - * Tests pure functions — no server, no processes, no network. - */ - -import { describe, test, expect } from 'bun:test'; -import { sanitizeExtensionUrl } from '../src/sidebar-utils'; - -describe('sanitizeExtensionUrl', () => { - test('passes valid http URL', () => { - expect(sanitizeExtensionUrl('http://example.com')).toBe('http://example.com/'); - }); - - test('passes valid https URL', () => { - expect(sanitizeExtensionUrl('https://example.com/page?q=1')).toBe('https://example.com/page?q=1'); - }); - - test('rejects chrome:// URLs', () => { - expect(sanitizeExtensionUrl('chrome://extensions')).toBeNull(); - }); - - test('rejects chrome-extension:// URLs', () => { - expect(sanitizeExtensionUrl('chrome-extension://abcdef/popup.html')).toBeNull(); - }); - - test('rejects javascript: URLs', () => { - expect(sanitizeExtensionUrl('javascript:alert(1)')).toBeNull(); - }); - - test('rejects file:// URLs', () => { - expect(sanitizeExtensionUrl('file:///etc/passwd')).toBeNull(); - }); - - test('rejects data: URLs', () => { - expect(sanitizeExtensionUrl('data:text/html,

hi

')).toBeNull(); - }); - - test('strips raw control characters from URL', () => { - // URL constructor percent-encodes \x00 as %00, which is safe - // The regex strips any remaining raw control chars after .href normalization - const result = sanitizeExtensionUrl('https://example.com/\x00page\x1f'); - expect(result).not.toBeNull(); - expect(result!).not.toMatch(/[\x00-\x1f\x7f]/); - }); - - test('strips newlines (prompt injection vector)', () => { - const result = sanitizeExtensionUrl('https://evil.com/%0AUser:%20ignore'); - // URL constructor normalizes %0A, control char stripping removes any raw newlines - expect(result).not.toBeNull(); - expect(result!).not.toContain('\n'); - }); - - test('truncates URLs longer than 2048 chars', () => { - const longUrl = 'https://example.com/' + 'a'.repeat(3000); - const result = sanitizeExtensionUrl(longUrl); - expect(result).not.toBeNull(); - expect(result!.length).toBeLessThanOrEqual(2048); - }); - - test('returns null for null input', () => { - expect(sanitizeExtensionUrl(null)).toBeNull(); - }); - - test('returns null for undefined input', () => { - expect(sanitizeExtensionUrl(undefined)).toBeNull(); - }); - - test('returns null for empty string', () => { - expect(sanitizeExtensionUrl('')).toBeNull(); - }); - - test('returns null for invalid URL string', () => { - expect(sanitizeExtensionUrl('not a url at all')).toBeNull(); - }); - - test('does not crash on weird input', () => { - expect(sanitizeExtensionUrl(':///')).toBeNull(); - expect(sanitizeExtensionUrl(' ')).toBeNull(); - expect(sanitizeExtensionUrl('\x00\x01\x02')).toBeNull(); - }); - - test('preserves query parameters and fragments', () => { - const url = 'https://example.com/search?q=test&page=2#results'; - expect(sanitizeExtensionUrl(url)).toBe(url); - }); - - test('preserves port numbers', () => { - expect(sanitizeExtensionUrl('http://localhost:3000/api')).toBe('http://localhost:3000/api'); - }); - - test('handles URL with auth (user:pass@host)', () => { - const result = sanitizeExtensionUrl('https://user:pass@example.com/'); - expect(result).not.toBeNull(); - expect(result).toContain('example.com'); - }); -}); diff --git a/browse/test/sidebar-ux.test.ts b/browse/test/sidebar-ux.test.ts deleted file mode 100644 index 98fc7ee97e..0000000000 --- a/browse/test/sidebar-ux.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Source-contract tests for the terminal-first browser sidepanel. - * - * The one-shot chat queue and sidebar-agent daemon were removed. These - * checks intentionally cover the current PTY surface and its retained debug - * tools without preserving obsolete chat implementation details. - */ - -import { describe, test, expect } from 'bun:test'; -import * as fs from 'fs'; -import * as path from 'path'; - -const BROWSE_ROOT = path.resolve(import.meta.dir, '..'); -const REPO_ROOT = path.resolve(BROWSE_ROOT, '..'); -const EXTENSION_ROOT = path.join(REPO_ROOT, 'extension'); - -const html = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel.html'), 'utf8'); -const sidepanel = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel.js'), 'utf8'); -const terminal = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel-terminal.js'), 'utf8'); -const background = fs.readFileSync(path.join(EXTENSION_ROOT, 'background.js'), 'utf8'); - -function between(source: string, startMarker: string, endMarker: string): string { - const start = source.indexOf(startMarker); - if (start < 0) return ''; - const end = source.indexOf(endMarker, start + startMarker.length); - return end < 0 ? source.slice(start) : source.slice(start, end); -} - -function withoutComments(source: string): string { - return source - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/^\s*\/\/.*$/gm, ''); -} - -describe('terminal-first sidepanel', () => { - test('terminal is the sole active primary pane', () => { - const activeMainIds = [...html.matchAll( - / match[1]); - - expect(activeMainIds).toEqual(['tab-terminal']); - expect(html).toContain('id="tab-terminal"'); - expect(html).toContain('role="tabpanel" aria-label="Terminal"'); - expect(html).not.toContain('id="tab-chat"'); - expect(sidepanel).toContain("const PRIMARY_PANE_ID = 'tab-terminal';"); - expect(sidepanel).toContain('document.getElementById(PRIMARY_PANE_ID).classList.add(\'active\')'); - }); - - test('xterm, fit, and terminal bootstrap assets are shipped and ordered', () => { - const assets = [ - 'lib/xterm.css', - 'lib/xterm.js', - 'lib/xterm-addon-fit.js', - 'sidepanel-terminal.js', - ]; - for (const asset of assets) { - expect(fs.existsSync(path.join(EXTENSION_ROOT, asset))).toBe(true); - expect(html).toContain(asset); - } - - const scriptOrder = [ - html.indexOf('lib/xterm.js'), - html.indexOf('lib/xterm-addon-fit.js'), - html.indexOf('sidepanel.js'), - html.indexOf('sidepanel-terminal.js'), - ]; - expect(scriptOrder.every((index) => index >= 0)).toBe(true); - expect(scriptOrder).toEqual([...scriptOrder].sort((left, right) => left - right)); - - for (const id of [ - 'terminal-bootstrap', - 'terminal-bootstrap-status', - 'terminal-install-card', - 'terminal-mount', - 'terminal-ended', - 'terminal-restart', - 'terminal-restart-now', - ]) { - expect(html).toContain(`id="${id}"`); - } - expect(terminal).toContain("setState(STATE.IDLE, { message: 'Starting Claude Code...' })"); - expect(terminal).toContain('tryAutoConnect();'); - }); - - test('retired chat queue code and daemon stay removed', () => { - const executableSidepanel = withoutComments(sidepanel); - const executableTerminal = withoutComments(terminal); - const removedFunctions = ['sendMessage', 'pollChat', 'switchChatTab']; - - expect(fs.existsSync(path.join(BROWSE_ROOT, 'src', 'sidebar-agent.ts'))).toBe(false); - for (const name of removedFunctions) { - const declaration = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\(`); - expect(executableSidepanel).not.toMatch(declaration); - expect(executableTerminal).not.toMatch(declaration); - } - expect(executableSidepanel).not.toContain('/sidebar-chat'); - expect(executableSidepanel).not.toContain('/sidebar-command'); - expect(html).not.toContain('id="chat-input"'); - expect(html).not.toContain('id="chat-messages"'); - expect(html).not.toContain('id="stop-agent-btn"'); - }); -}); - -describe('PTY lifecycle security', () => { - test('bootstrap uses authenticated POST and a one-use WebSocket protocol token', () => { - const connection = between(sidepanel, 'function updateConnection(', '// ─── Port Configuration'); - const mint = between(terminal, 'async function mintSession()', 'function startReattachLoop('); - - expect(connection).toContain('window.gstackServerPort'); - expect(connection).toContain('window.gstackAuthToken'); - expect(mint).toContain('`http://127.0.0.1:${serverPort}/pty-session`'); - expect(mint).toContain("method: 'POST'"); - expect(mint).toContain("'Authorization': `Bearer ${token}`"); - expect(mint).toContain("credentials: 'include'"); - expect(terminal).toContain('const attachToken = minted.attachToken || minted.ptySessionToken'); - expect(terminal).toContain( - 'new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`])', - ); - expect(terminal).not.toContain('?token='); - }); - - test('session identity is retained only for explicit pagehide disposal', () => { - const disposal = sidepanel.slice(sidepanel.indexOf("window.addEventListener('pagehide'")); - - expect(terminal).toContain('currentSessionId = sessionId || null'); - expect(terminal).toContain('window.gstackPtySession = currentSessionId'); - expect(disposal).toContain('const sessionId = window.gstackPtySession'); - expect(disposal).toContain('const authToken = window.gstackAuthToken'); - expect(disposal).toContain('if (!sessionId || !authToken || !port) return'); - expect(disposal).toContain('JSON.stringify({ sessionId, authToken })'); - expect(disposal).toContain('navigator.sendBeacon(`http://127.0.0.1:${port}/pty-dispose`, blob)'); - expect(disposal).not.toContain('?token='); - }); - - test('tab state crosses the extension boundary only through the live PTY relay', () => { - const push = between(background, 'async function pushTabState(', "chrome.tabs.onActivated.addListener"); - const sidepanelRelay = between(sidepanel, "if (msg.type === 'browserTabState')", '// ─── v1.44 pagehide'); - const terminalRelay = between( - terminal, - "document.addEventListener('gstack:tab-state'", - '// Repaint after a debug-tab', - ); - - expect(push).toContain("type: 'browserTabState'"); - expect(push).toContain('...snapshot'); - expect(background).toContain("pushTabState('activated')"); - expect(background).toContain("pushTabState('created')"); - expect(background).toContain("pushTabState('removed')"); - expect(sidepanelRelay).toContain("new CustomEvent('gstack:tab-state'"); - expect(sidepanelRelay).toContain('detail: { active: msg.active, tabs: msg.tabs, reason: msg.reason }'); - expect(terminalRelay).toContain('if (!ws || ws.readyState !== WebSocket.OPEN) return'); - expect(terminalRelay).toContain("type: 'tabState'"); - expect(terminalRelay).toContain('active: ev.detail?.active'); - expect(terminalRelay).toContain('tabs: ev.detail?.tabs'); - }); - - test('page-derived inspector and cleanup prompts are scanned before PTY injection', () => { - const inspectorSend = between(sidepanel, "inspectorSendBtn.addEventListener('click'", '// ─── Quick Action Helpers'); - const cleanup = between(sidepanel, 'async function runCleanup(', 'async function runScreenshot('); - - for (const block of [inspectorSend, cleanup]) { - const scan = block.indexOf('gstackScanForPTYInject'); - const inject = block.indexOf('gstackInjectToTerminal'); - expect(scan).toBeGreaterThan(0); - expect(inject).toBeGreaterThan(scan); - expect(block).toContain("verdict === 'BLOCK'"); - expect(block).toContain("verdict === 'WARN'"); - } - }); -}); - -describe('retained debug tools and quick actions', () => { - test('activity, refs, and inspector remain hidden debug panels', () => { - const debugNav = between(html, '