mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-07 14:06:42 +02:00
merge: resolve version conflict with main (keep 0.15.5.0)
This commit is contained in:
+134
-18
@@ -107,6 +107,8 @@ export class BrowserManager {
|
||||
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
|
||||
@@ -219,17 +221,26 @@ export class BrowserManager {
|
||||
|
||||
// Find the gstack extension directory for auto-loading
|
||||
const extensionPath = this.findExtensionPath();
|
||||
const launchArgs = ['--hide-crash-restore-bubble'];
|
||||
const launchArgs = [
|
||||
'--hide-crash-restore-bubble',
|
||||
// Anti-bot-detection: remove the navigator.webdriver flag that Playwright sets.
|
||||
// Sites like Google and NYTimes check this to block automation browsers.
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
];
|
||||
if (extensionPath) {
|
||||
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
|
||||
launchArgs.push(`--load-extension=${extensionPath}`);
|
||||
// Write auth token for extension bootstrap (read via chrome.runtime.getURL)
|
||||
// Write auth token for extension bootstrap.
|
||||
// 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 authFile = path.join(extensionPath, '.auth.json');
|
||||
const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack');
|
||||
fs.mkdirSync(gstackDir, { recursive: true });
|
||||
const authFile = path.join(gstackDir, '.auth.json');
|
||||
try {
|
||||
fs.writeFileSync(authFile, JSON.stringify({ token: authToken }), { mode: 0o600 });
|
||||
fs.writeFileSync(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 }), { mode: 0o600 });
|
||||
} catch (err: any) {
|
||||
console.warn(`[browse] Could not write .auth.json: ${err.message}`);
|
||||
}
|
||||
@@ -245,10 +256,74 @@ export class BrowserManager {
|
||||
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
|
||||
// Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var.
|
||||
// Used by GStack Browser.app to point at the bundled Chromium.
|
||||
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
|
||||
|
||||
// Rebrand Chromium → GStack Browser in macOS menu bar / Dock / Cmd+Tab.
|
||||
// Patch the Chromium .app's Info.plist so macOS shows our name.
|
||||
// This works for both dev mode (system Playwright cache) and .app bundle.
|
||||
const chromePath = executablePath || chromium.executablePath();
|
||||
try {
|
||||
// Walk up from binary to the .app's Info.plist
|
||||
// e.g. .../Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing
|
||||
// → .../Google Chrome for Testing.app/Contents/Info.plist
|
||||
const chromeContentsDir = path.resolve(path.dirname(chromePath), '..');
|
||||
const chromePlist = path.join(chromeContentsDir, 'Info.plist');
|
||||
if (fs.existsSync(chromePlist)) {
|
||||
const plistContent = fs.readFileSync(chromePlist, 'utf-8');
|
||||
if (plistContent.includes('Google Chrome for Testing')) {
|
||||
const patched = plistContent
|
||||
.replace(/Google Chrome for Testing/g, 'GStack Browser');
|
||||
fs.writeFileSync(chromePlist, patched);
|
||||
}
|
||||
// Replace Chromium's Dock icon with ours (Chromium's process owns the Dock icon)
|
||||
const iconCandidates = [
|
||||
path.join(__dirname, '..', '..', 'scripts', 'app', 'icon.icns'), // repo dev mode
|
||||
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'scripts', 'app', 'icon.icns'), // global install
|
||||
];
|
||||
const iconSrc = iconCandidates.find(p => fs.existsSync(p));
|
||||
if (iconSrc) {
|
||||
const chromeResources = path.join(chromeContentsDir, 'Resources');
|
||||
// Read original icon name from plist
|
||||
const iconMatch = plistContent.match(/<key>CFBundleIconFile<\/key>\s*<string>([^<]+)<\/string>/);
|
||||
let origIcon = iconMatch ? iconMatch[1] : 'app';
|
||||
if (!origIcon.endsWith('.icns')) origIcon += '.icns';
|
||||
const destIcon = path.join(chromeResources, origIcon);
|
||||
try { fs.copyFileSync(iconSrc, destIcon); } catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: app name just stays as Chrome for Testing
|
||||
}
|
||||
|
||||
// Build custom user agent: keep Chrome version for site compatibility,
|
||||
// but replace "Chrome for Testing" branding with "GStackBrowser"
|
||||
let customUA: string | undefined;
|
||||
if (!this.customUserAgent) {
|
||||
// Detect Chrome version from the Chromium binary
|
||||
const chromePath = executablePath || chromium.executablePath();
|
||||
try {
|
||||
const versionProc = Bun.spawnSync([chromePath, '--version'], {
|
||||
stdout: 'pipe', stderr: 'pipe', timeout: 5000,
|
||||
});
|
||||
const versionOutput = versionProc.stdout.toString().trim();
|
||||
// Output like: "Google Chrome for Testing 145.0.6422.0" or "Chromium 145.0.6422.0"
|
||||
const versionMatch = versionOutput.match(/(\d+\.\d+\.\d+\.\d+)/);
|
||||
const chromeVersion = versionMatch ? versionMatch[1] : '131.0.0.0';
|
||||
customUA = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36 GStackBrowser`;
|
||||
} catch {
|
||||
// Fallback: generic modern Chrome UA
|
||||
customUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 GStackBrowser';
|
||||
}
|
||||
}
|
||||
|
||||
this.context = await chromium.launchPersistentContext(userDataDir, {
|
||||
headless: false,
|
||||
args: launchArgs,
|
||||
viewport: null, // Use browser's default viewport (real window size)
|
||||
userAgent: this.customUserAgent || customUA,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
// Playwright adds flags that block extension loading
|
||||
ignoreDefaultArgs: [
|
||||
'--disable-extensions',
|
||||
@@ -259,6 +334,59 @@ export class BrowserManager {
|
||||
this.connectionMode = 'headed';
|
||||
this.intentionalDisconnect = false;
|
||||
|
||||
// ─── Anti-bot-detection stealth patches ───────────────────────
|
||||
// Playwright's Chromium is detected by sites like Google/NYTimes via:
|
||||
// 1. navigator.webdriver = true (handled by --disable-blink-features above)
|
||||
// 2. Missing plugins array (real Chrome has PDF viewer, etc.)
|
||||
// 3. Missing languages
|
||||
// 4. CDP runtime detection (window.cdc_* variables)
|
||||
// 5. Permissions API returning 'denied' for notifications
|
||||
await this.context.addInitScript(() => {
|
||||
// Fake plugins array (real Chrome has at least PDF Viewer)
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => {
|
||||
const plugins = [
|
||||
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||
];
|
||||
(plugins as any).namedItem = (name: string) => plugins.find(p => p.name === name) || null;
|
||||
(plugins as any).refresh = () => {};
|
||||
return plugins;
|
||||
},
|
||||
});
|
||||
|
||||
// Fake languages (Playwright sometimes sends empty)
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['en-US', 'en'],
|
||||
});
|
||||
|
||||
// Remove CDP runtime artifacts that automation detectors look for
|
||||
// cdc_ prefixed vars are injected by ChromeDriver/CDP
|
||||
const cleanup = () => {
|
||||
for (const key of Object.keys(window)) {
|
||||
if (key.startsWith('cdc_') || key.startsWith('__webdriver')) {
|
||||
try { delete (window as any)[key]; } catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
cleanup();
|
||||
// Re-clean after a tick in case they're injected late
|
||||
setTimeout(cleanup, 0);
|
||||
|
||||
// Override Permissions API to return 'prompt' for notifications
|
||||
// (automation browsers return 'denied' which is a fingerprint)
|
||||
const originalQuery = window.navigator.permissions?.query;
|
||||
if (originalQuery) {
|
||||
(window.navigator.permissions as any).query = (params: any) => {
|
||||
if (params.name === 'notifications') {
|
||||
return Promise.resolve({ state: 'prompt', onchange: null } as PermissionStatus);
|
||||
}
|
||||
return originalQuery.call(window.navigator.permissions, params);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Inject visual indicator — subtle top-edge amber gradient
|
||||
// Extension's content script handles the floating pill
|
||||
const indicatorScript = () => {
|
||||
@@ -825,20 +953,8 @@ export class BrowserManager {
|
||||
if (extensionPath) {
|
||||
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
|
||||
launchArgs.push(`--load-extension=${extensionPath}`);
|
||||
// Write auth token for extension bootstrap during handoff
|
||||
if (this.serverPort) {
|
||||
try {
|
||||
const { resolveConfig } = require('./config');
|
||||
const config = resolveConfig();
|
||||
const stateFile = path.join(config.stateDir, 'browse.json');
|
||||
if (fs.existsSync(stateFile)) {
|
||||
const stateData = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
if (stateData.token) {
|
||||
fs.writeFileSync(path.join(extensionPath, '.auth.json'), JSON.stringify({ token: stateData.token }), { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// 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');
|
||||
|
||||
+10
-1
@@ -330,12 +330,21 @@ async function ensureServer(): Promise<ServerState> {
|
||||
return state;
|
||||
}
|
||||
|
||||
// BROWSE_NO_AUTOSTART: sidebar agent sets this so the child claude never
|
||||
// spawns an invisible headless browser. If the headed server is down,
|
||||
// fail fast with a clear error instead of silently starting a new one.
|
||||
if (process.env.BROWSE_NO_AUTOSTART === '1') {
|
||||
console.error('[browse] Server not available and BROWSE_NO_AUTOSTART is set.');
|
||||
console.error('[browse] The headed browser may have been closed. Run /open-gstack-browser to restart.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Guard: never silently replace a headed server with a headless one.
|
||||
// Headed mode means a user-visible Chrome window is (or was) controlled.
|
||||
// Silently replacing it would be confusing — tell the user to reconnect.
|
||||
if (state && state.mode === 'headed' && isProcessAlive(state.pid)) {
|
||||
console.error(`[browse] Headed server running (PID ${state.pid}) but not responding.`);
|
||||
console.error(`[browse] Run '$B connect' to restart.`);
|
||||
console.error(`[browse] Run '/open-gstack-browser' to restart.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,15 @@ export function getCookiePickerHTML(serverPort: number, authToken?: string): str
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
padding: 10px 24px 12px;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #222;
|
||||
background: #0f0f0f;
|
||||
}
|
||||
|
||||
/* ─── Layout ──────────────────────────── */
|
||||
.container {
|
||||
display: flex;
|
||||
@@ -300,6 +309,8 @@ export function getCookiePickerHTML(serverPort: number, authToken?: string): str
|
||||
<span class="port">localhost:${serverPort}</span>
|
||||
</div>
|
||||
|
||||
<p class="subtitle">Select the domains of cookies you want to import to GStack Browser. You'll be able to browse those sites with the same login as your other browser.</p>
|
||||
|
||||
<div id="banner" class="banner"></div>
|
||||
|
||||
<div class="container">
|
||||
|
||||
+198
-39
@@ -46,6 +46,31 @@ function validateAuth(req: Request): boolean {
|
||||
return header === `Bearer ${AUTH_TOKEN}`;
|
||||
}
|
||||
|
||||
// ─── Sidebar Model Router ────────────────────────────────────────
|
||||
// Fast model for navigation/interaction, smart model for reading/analysis.
|
||||
// The delta between sonnet and opus on "click @e24" is 5-10x in latency
|
||||
// and cost, with zero quality difference. Save opus for when you need it.
|
||||
|
||||
const ANALYSIS_WORDS = /\b(what|why|how|explain|describe|summarize|analyze|compare|review|read\b.*\b(and|then)|tell\s*me|find.*bugs?|check.*for|assess|evaluate|report)\b/i;
|
||||
const ACTION_PATTERNS = /^(go\s*to|open|navigate|click|tap|press|fill|type|enter|scroll|screenshot|snap|reload|refresh|back|forward|close|submit|select|toggle|expand|collapse|dismiss|accept|upload|download|focus|hover|cleanup|clean\s*up)\b/i;
|
||||
const ACTION_ANYWHERE = /\b(go\s*to|click|tap|fill\s*(in|out)?|type\s*in|navigate\s*to|open\s*(the|this|that)?|take\s*a?\s*screenshot|scroll\s*(down|up|to)|reload|refresh|submit|press\s*(the|enter|button))\b/i;
|
||||
|
||||
function pickSidebarModel(message: string): string {
|
||||
const msg = message.trim();
|
||||
|
||||
// Analysis/comprehension always gets opus — regardless of action verbs mixed in
|
||||
if (ANALYSIS_WORDS.test(msg)) return 'opus';
|
||||
|
||||
// Short action commands (under ~80 chars, starts with an action verb)
|
||||
if (msg.length < 80 && ACTION_PATTERNS.test(msg)) return 'sonnet';
|
||||
|
||||
// Longer messages that are clearly action-oriented (no analysis words already checked above)
|
||||
if (ACTION_ANYWHERE.test(msg)) return 'sonnet';
|
||||
|
||||
// Everything else: multi-step, ambiguous, or complex
|
||||
return 'opus';
|
||||
}
|
||||
|
||||
// ─── Help text (auto-generated from COMMAND_DESCRIPTIONS) ────────
|
||||
function generateHelpText(): string {
|
||||
// Group commands by category
|
||||
@@ -246,7 +271,9 @@ function addChatEntry(entry: Omit<ChatEntry, 'id'>, tabId?: number): ChatEntry {
|
||||
// Persist to disk (best-effort)
|
||||
if (sidebarSession) {
|
||||
const chatFile = path.join(SESSIONS_DIR, sidebarSession.id, 'chat.jsonl');
|
||||
try { fs.appendFileSync(chatFile, JSON.stringify(full) + '\n'); } catch {}
|
||||
try { fs.appendFileSync(chatFile, JSON.stringify(full) + '\n'); } catch (err: any) {
|
||||
console.error('[browse] Failed to persist chat entry:', err.message);
|
||||
}
|
||||
}
|
||||
return full;
|
||||
}
|
||||
@@ -271,11 +298,17 @@ function loadSession(): SidebarSession | null {
|
||||
const chatFile = path.join(SESSIONS_DIR, session.id, 'chat.jsonl');
|
||||
try {
|
||||
const lines = fs.readFileSync(chatFile, 'utf-8').split('\n').filter(Boolean);
|
||||
chatBuffer = lines.map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
||||
const parsed = lines.map(line => { try { return JSON.parse(line); } catch { return null; } });
|
||||
const discarded = parsed.filter(x => x === null).length;
|
||||
if (discarded > 0) console.warn(`[browse] Discarding ${discarded} corrupted chat entries during load`);
|
||||
chatBuffer = parsed.filter(Boolean);
|
||||
chatNextId = chatBuffer.length > 0 ? Math.max(...chatBuffer.map(e => e.id)) + 1 : 0;
|
||||
} catch {}
|
||||
} catch (err: any) {
|
||||
if (err.code !== 'ENOENT') console.warn('[browse] Chat history not loaded:', err.message);
|
||||
}
|
||||
return session;
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
if (err.code !== 'ENOENT') console.error('[browse] Failed to load session:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -303,7 +336,9 @@ function createWorktree(sessionId: string): string | null {
|
||||
Bun.spawnSync(['git', 'worktree', 'remove', '--force', worktreeDir], {
|
||||
cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', timeout: 5000,
|
||||
});
|
||||
try { fs.rmSync(worktreeDir, { recursive: true, force: true }); } catch {}
|
||||
try { fs.rmSync(worktreeDir, { recursive: true, force: true }); } catch (err: any) {
|
||||
console.warn('[browse] Failed to clean stale worktree dir:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Get current branch/commit
|
||||
@@ -343,8 +378,12 @@ function removeWorktree(worktreePath: string | null): void {
|
||||
});
|
||||
}
|
||||
// Cleanup dir if git worktree remove didn't
|
||||
try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch {}
|
||||
} catch {}
|
||||
try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch (err: any) {
|
||||
console.warn('[browse] Failed to remove worktree dir:', worktreePath, err.message);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[browse] Worktree removal error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function createSession(): SidebarSession {
|
||||
@@ -372,7 +411,9 @@ function saveSession(): void {
|
||||
if (!sidebarSession) return;
|
||||
sidebarSession.lastActiveAt = new Date().toISOString();
|
||||
const sessionFile = path.join(SESSIONS_DIR, sidebarSession.id, 'session.json');
|
||||
try { fs.writeFileSync(sessionFile, JSON.stringify(sidebarSession, null, 2)); } catch {}
|
||||
try { fs.writeFileSync(sessionFile, JSON.stringify(sidebarSession, null, 2)); } catch (err: any) {
|
||||
console.error('[browse] Failed to save session:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function listSessions(): Array<SidebarSession & { chatLines: number }> {
|
||||
@@ -382,11 +423,16 @@ function listSessions(): Array<SidebarSession & { chatLines: number }> {
|
||||
try {
|
||||
const session = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, d, 'session.json'), 'utf-8'));
|
||||
let chatLines = 0;
|
||||
try { chatLines = fs.readFileSync(path.join(SESSIONS_DIR, d, 'chat.jsonl'), 'utf-8').split('\n').filter(Boolean).length; } catch {}
|
||||
try { chatLines = fs.readFileSync(path.join(SESSIONS_DIR, d, 'chat.jsonl'), 'utf-8').split('\n').filter(Boolean).length; } catch {
|
||||
// Expected: no chat file yet
|
||||
}
|
||||
return { ...session, chatLines };
|
||||
} catch { return null; }
|
||||
}).filter(Boolean);
|
||||
} catch { return []; }
|
||||
} catch (err: any) {
|
||||
console.warn('[browse] Failed to list sessions:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function processAgentEvent(event: any): void {
|
||||
@@ -482,7 +528,14 @@ function spawnClaude(userMessage: string, extensionUrl?: string | null, forTabId
|
||||
const prompt = `${systemPrompt}\n\n<user-message>\n${escapedMessage}\n</user-message>`;
|
||||
// Never resume — each message is a fresh context. Resuming carries stale
|
||||
// page URLs and old navigation state that makes the agent fight the user.
|
||||
const args = ['-p', prompt, '--model', 'opus', '--output-format', 'stream-json', '--verbose',
|
||||
|
||||
// Auto model routing: fast model for navigation/interaction, smart model for reading/analysis.
|
||||
// Navigation, clicking, filling forms, screenshots = deterministic tool calls, no thinking needed.
|
||||
// Reading, summarizing, analyzing, explaining = needs comprehension.
|
||||
const model = pickSidebarModel(userMessage);
|
||||
console.log(`[browse] Sidebar model: ${model} for "${userMessage.slice(0, 60)}"`);
|
||||
|
||||
const args = ['-p', prompt, '--model', model, '--output-format', 'stream-json', '--verbose',
|
||||
'--allowedTools', 'Bash,Read,Glob,Grep'];
|
||||
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_start' });
|
||||
@@ -521,8 +574,12 @@ function spawnClaude(userMessage: string, extensionUrl?: string | null, forTabId
|
||||
|
||||
function killAgent(): void {
|
||||
if (agentProcess) {
|
||||
try { agentProcess.kill('SIGTERM'); } catch {}
|
||||
setTimeout(() => { try { agentProcess?.kill('SIGKILL'); } catch {} }, 3000);
|
||||
try { agentProcess.kill('SIGTERM'); } catch (err: any) {
|
||||
console.warn('[browse] Failed to SIGTERM agent:', err.message);
|
||||
}
|
||||
setTimeout(() => { try { agentProcess?.kill('SIGKILL'); } catch (err: any) {
|
||||
console.warn('[browse] Failed to SIGKILL agent:', err.message);
|
||||
} }, 3000);
|
||||
}
|
||||
agentProcess = null;
|
||||
agentStartTime = null;
|
||||
@@ -600,8 +657,8 @@ async function flushBuffers() {
|
||||
fs.appendFileSync(DIALOG_LOG_PATH, lines);
|
||||
lastDialogFlushed = dialogBuffer.totalAdded;
|
||||
}
|
||||
} catch {
|
||||
// Flush failures are non-fatal — buffers are in memory
|
||||
} catch (err: any) {
|
||||
console.error('[browse] Buffer flush failed:', err.message);
|
||||
} finally {
|
||||
flushInProgress = false;
|
||||
}
|
||||
@@ -618,6 +675,9 @@ function resetIdleTimer() {
|
||||
}
|
||||
|
||||
const idleCheckInterval = setInterval(() => {
|
||||
// Headed mode: the user is looking at the browser. Never auto-die.
|
||||
// Only shut down when the user explicitly disconnects or closes the window.
|
||||
if (browserManager.getConnectionMode() === 'headed') return;
|
||||
if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
|
||||
console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`);
|
||||
shutdown();
|
||||
@@ -639,7 +699,9 @@ const inspectorSubscribers = new Set<InspectorSubscriber>();
|
||||
function emitInspectorEvent(event: any): void {
|
||||
for (const notify of inspectorSubscribers) {
|
||||
queueMicrotask(() => {
|
||||
try { notify(event); } catch {}
|
||||
try { notify(event); } catch (err: any) {
|
||||
console.error('[browse] Inspector event subscriber threw:', err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -725,7 +787,9 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
if (tabId !== undefined && tabId !== null) {
|
||||
savedTabId = browserManager.getActiveTabId();
|
||||
// bringToFront: false — internal tab pinning must NOT steal window focus
|
||||
try { browserManager.switchTab(tabId, { bringToFront: false }); } catch {}
|
||||
try { browserManager.switchTab(tabId, { bringToFront: false }); } catch (err: any) {
|
||||
console.warn('[browse] Failed to pin tab', tabId, ':', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Block mutation commands while watching (read-only observation mode)
|
||||
@@ -809,7 +873,9 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
browserManager.resetFailures();
|
||||
// Restore original active tab if we pinned to a specific one
|
||||
if (savedTabId !== null) {
|
||||
try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch {}
|
||||
try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) {
|
||||
console.warn('[browse] Failed to restore tab after command:', restoreErr.message);
|
||||
}
|
||||
}
|
||||
return new Response(result, {
|
||||
status: 200,
|
||||
@@ -818,7 +884,9 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
} catch (err: any) {
|
||||
// Restore original active tab even on error
|
||||
if (savedTabId !== null) {
|
||||
try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch {}
|
||||
try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) {
|
||||
console.warn('[browse] Failed to restore tab after error:', restoreErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Activity: emit command_end (error)
|
||||
@@ -850,8 +918,19 @@ async function shutdown() {
|
||||
isShuttingDown = true;
|
||||
|
||||
console.log('[browse] Shutting down...');
|
||||
// Kill the sidebar-agent daemon process (spawned by cli.ts, detached).
|
||||
// Without this, the agent keeps polling a dead server and spawns confused
|
||||
// claude processes that auto-start headless browsers.
|
||||
try {
|
||||
const { spawnSync } = require('child_process');
|
||||
spawnSync('pkill', ['-f', 'sidebar-agent\\.ts'], { stdio: 'ignore', timeout: 3000 });
|
||||
} catch (err: any) {
|
||||
console.warn('[browse] Failed to kill sidebar-agent:', err.message);
|
||||
}
|
||||
// Clean up CDP inspector sessions
|
||||
try { detachSession(); } catch {}
|
||||
try { detachSession(); } catch (err: any) {
|
||||
console.warn('[browse] Failed to detach CDP session:', err.message);
|
||||
}
|
||||
inspectorSubscribers.clear();
|
||||
// Stop watch mode if active
|
||||
if (browserManager.isWatching()) browserManager.stopWatch();
|
||||
@@ -869,11 +948,15 @@ async function shutdown() {
|
||||
// Clean up Chromium profile locks (prevent SingletonLock on next launch)
|
||||
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {}
|
||||
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch (err: any) {
|
||||
console.debug('[browse] Lock cleanup:', lockFile, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up state file
|
||||
try { fs.unlinkSync(config.stateFile); } catch {}
|
||||
try { fs.unlinkSync(config.stateFile); } catch (err: any) {
|
||||
console.debug('[browse] State file cleanup:', err.message);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -885,7 +968,9 @@ process.on('SIGINT', shutdown);
|
||||
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
|
||||
if (process.platform === 'win32') {
|
||||
process.on('exit', () => {
|
||||
try { fs.unlinkSync(config.stateFile); } catch {}
|
||||
try { fs.unlinkSync(config.stateFile); } catch {
|
||||
// Best-effort on exit
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -894,15 +979,23 @@ function emergencyCleanup() {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
// Kill agent subprocess if running
|
||||
try { killAgent(); } catch {}
|
||||
try { killAgent(); } catch (err: any) {
|
||||
console.error('[browse] Emergency: failed to kill agent:', err.message);
|
||||
}
|
||||
// Save session state so chat history persists across crashes
|
||||
try { saveSession(); } catch {}
|
||||
try { saveSession(); } catch (err: any) {
|
||||
console.error('[browse] Emergency: failed to save session:', err.message);
|
||||
}
|
||||
// Clean Chromium profile locks
|
||||
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {}
|
||||
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch (err: any) {
|
||||
console.debug('[browse] Emergency lock cleanup:', lockFile, err.message);
|
||||
}
|
||||
}
|
||||
try { fs.unlinkSync(config.stateFile); } catch (err: any) {
|
||||
console.debug('[browse] Emergency state cleanup:', err.message);
|
||||
}
|
||||
try { fs.unlinkSync(config.stateFile); } catch {}
|
||||
}
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[browse] FATAL uncaught exception:', err.message);
|
||||
@@ -918,9 +1011,15 @@ process.on('unhandledRejection', (err: any) => {
|
||||
// ─── Start ─────────────────────────────────────────────────────
|
||||
async function start() {
|
||||
// Clear old log files
|
||||
try { fs.unlinkSync(CONSOLE_LOG_PATH); } catch {}
|
||||
try { fs.unlinkSync(NETWORK_LOG_PATH); } catch {}
|
||||
try { fs.unlinkSync(DIALOG_LOG_PATH); } catch {}
|
||||
try { fs.unlinkSync(CONSOLE_LOG_PATH); } catch (err: any) {
|
||||
if (err.code !== 'ENOENT') console.debug('[browse] Log cleanup console:', err.message);
|
||||
}
|
||||
try { fs.unlinkSync(NETWORK_LOG_PATH); } catch (err: any) {
|
||||
if (err.code !== 'ENOENT') console.debug('[browse] Log cleanup network:', err.message);
|
||||
}
|
||||
try { fs.unlinkSync(DIALOG_LOG_PATH); } catch (err: any) {
|
||||
if (err.code !== 'ENOENT') console.debug('[browse] Log cleanup dialog:', err.message);
|
||||
}
|
||||
|
||||
const port = await findPort();
|
||||
|
||||
@@ -949,6 +1048,35 @@ async function start() {
|
||||
return handleCookiePickerRoute(url, req, browserManager, AUTH_TOKEN);
|
||||
}
|
||||
|
||||
// Welcome page — served when GStack Browser launches in headed mode
|
||||
if (url.pathname === '/welcome') {
|
||||
const welcomePath = (() => {
|
||||
// Check project-local designs first, then global
|
||||
const slug = process.env.GSTACK_SLUG || 'unknown';
|
||||
const projectWelcome = `${process.env.HOME}/.gstack/projects/${slug}/designs/welcome-page-20260331/finalized.html`;
|
||||
try { if (require('fs').existsSync(projectWelcome)) return projectWelcome; } catch (err: any) {
|
||||
console.warn('[browse] Error checking project welcome page:', err.message);
|
||||
}
|
||||
// Fallback: built-in welcome page from gstack install
|
||||
const skillRoot = process.env.GSTACK_SKILL_ROOT || `${process.env.HOME}/.claude/skills/gstack`;
|
||||
const builtinWelcome = `${skillRoot}/browse/src/welcome.html`;
|
||||
try { if (require('fs').existsSync(builtinWelcome)) return builtinWelcome; } catch (err: any) {
|
||||
console.warn('[browse] Error checking builtin welcome page:', err.message);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
if (welcomePath) {
|
||||
try {
|
||||
const html = require('fs').readFileSync(welcomePath, 'utf-8');
|
||||
return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
} catch (err: any) {
|
||||
console.error('[browse] Failed to read welcome page:', welcomePath, err.message);
|
||||
}
|
||||
}
|
||||
// No welcome page found — redirect to about:blank
|
||||
return new Response('', { status: 302, headers: { 'Location': 'about:blank' } });
|
||||
}
|
||||
|
||||
// Health check — no auth required, does NOT reset idle timer
|
||||
if (url.pathname === '/health') {
|
||||
const healthy = await browserManager.isHealthy();
|
||||
@@ -958,7 +1086,10 @@ async function start() {
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
tabs: browserManager.getTabCount(),
|
||||
currentUrl: browserManager.getCurrentUrl(),
|
||||
// token removed — see .auth.json for extension bootstrap
|
||||
// Auth token for extension bootstrap. Safe: /health is localhost-only.
|
||||
// Previously served via .auth.json in extension dir, but that breaks
|
||||
// read-only .app bundles and codesigning. Extension reads token from here.
|
||||
token: AUTH_TOKEN,
|
||||
chatEnabled: true,
|
||||
agent: {
|
||||
status: agentStatus,
|
||||
@@ -1020,7 +1151,8 @@ async function start() {
|
||||
const unsubscribe = subscribe((entry) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`event: activity\ndata: ${JSON.stringify(entry)}\n\n`));
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
console.debug('[browse] Activity SSE stream error, unsubscribing:', err.message);
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
@@ -1029,7 +1161,8 @@ async function start() {
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
console.debug('[browse] Activity SSE heartbeat failed:', err.message);
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
}
|
||||
@@ -1039,7 +1172,9 @@ async function start() {
|
||||
req.signal.addEventListener('abort', () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
try { controller.close(); } catch {}
|
||||
try { controller.close(); } catch {
|
||||
// Expected: stream already closed
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1142,6 +1277,7 @@ async function start() {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
resetIdleTimer(); // Sidebar chat is real user activity
|
||||
const body = await req.json();
|
||||
const msg = body.message?.trim();
|
||||
if (!msg) {
|
||||
@@ -1188,7 +1324,9 @@ async function start() {
|
||||
chatBuffer = [];
|
||||
chatNextId = 0;
|
||||
if (sidebarSession) {
|
||||
try { fs.writeFileSync(path.join(SESSIONS_DIR, sidebarSession.id, 'chat.jsonl'), ''); } catch {}
|
||||
try { fs.writeFileSync(path.join(SESSIONS_DIR, sidebarSession.id, 'chat.jsonl'), ''); } catch (err: any) {
|
||||
console.error('[browse] Failed to clear chat file:', err.message);
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
@@ -1429,7 +1567,8 @@ async function start() {
|
||||
controller.enqueue(encoder.encode(
|
||||
`event: inspector\ndata: ${JSON.stringify(event)}\n\n`
|
||||
));
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
console.debug('[browse] Inspector SSE stream error:', err.message);
|
||||
inspectorSubscribers.delete(notify);
|
||||
}
|
||||
};
|
||||
@@ -1439,7 +1578,8 @@ async function start() {
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
console.debug('[browse] Inspector SSE heartbeat failed:', err.message);
|
||||
clearInterval(heartbeat);
|
||||
inspectorSubscribers.delete(notify);
|
||||
}
|
||||
@@ -1449,7 +1589,9 @@ async function start() {
|
||||
req.signal.addEventListener('abort', () => {
|
||||
clearInterval(heartbeat);
|
||||
inspectorSubscribers.delete(notify);
|
||||
try { controller.close(); } catch {}
|
||||
try { controller.close(); } catch (err: any) {
|
||||
// Expected: stream already closed
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1491,6 +1633,21 @@ async function start() {
|
||||
|
||||
browserManager.serverPort = port;
|
||||
|
||||
// Navigate to welcome page if in headed mode and still on about:blank
|
||||
if (browserManager.getConnectionMode() === 'headed') {
|
||||
try {
|
||||
const currentUrl = browserManager.getCurrentUrl();
|
||||
if (currentUrl === 'about:blank' || currentUrl === '') {
|
||||
const page = browserManager.getPage();
|
||||
page.goto(`http://127.0.0.1:${port}/welcome`, { timeout: 3000 }).catch((err: any) => {
|
||||
console.warn('[browse] Failed to navigate to welcome page:', err.message);
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[browse] Welcome page navigation setup failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale state files (older than 7 days)
|
||||
try {
|
||||
const stateDir = path.join(config.stateDir, 'browse-states');
|
||||
@@ -1505,7 +1662,9 @@ async function start() {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch (err: any) {
|
||||
console.warn('[browse] Failed to clean stale state files:', err.message);
|
||||
}
|
||||
|
||||
console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
|
||||
console.log(`[browse] State file: ${config.stateFile}`);
|
||||
|
||||
+40
-10
@@ -30,7 +30,8 @@ function getGitRoot(): string | null {
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
console.debug('[sidebar-agent] Not in a git repo:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -74,7 +75,8 @@ async function refreshToken(): Promise<string | null> {
|
||||
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
authToken = data.token || null;
|
||||
return authToken;
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
console.error('[sidebar-agent] Failed to refresh auth token:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -165,7 +167,11 @@ function describeToolCall(tool: string, input: any): string {
|
||||
return short.length > 100 ? short.slice(0, 100) + '…' : short;
|
||||
}
|
||||
|
||||
if (tool === 'Read' && input.file_path) return `Reading ${shorten(input.file_path)}`;
|
||||
if (tool === 'Read' && input.file_path) {
|
||||
// Skip Claude's internal tool-result file reads — they're plumbing, not user-facing
|
||||
if (input.file_path.includes('/tool-results/') || input.file_path.includes('/.claude/projects/')) return '';
|
||||
return `Reading ${shorten(input.file_path)}`;
|
||||
}
|
||||
if (tool === 'Edit' && input.file_path) return `Editing ${shorten(input.file_path)}`;
|
||||
if (tool === 'Write' && input.file_path) return `Writing ${shorten(input.file_path)}`;
|
||||
if (tool === 'Grep' && input.pattern) return `Searching for "${input.pattern}"`;
|
||||
@@ -234,7 +240,10 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
|
||||
// Validate cwd exists — queue may reference a stale worktree
|
||||
let effectiveCwd = cwd || process.cwd();
|
||||
try { fs.accessSync(effectiveCwd); } catch { effectiveCwd = process.cwd(); }
|
||||
try { fs.accessSync(effectiveCwd); } catch (err: any) {
|
||||
console.warn('[sidebar-agent] Worktree path inaccessible, falling back to cwd:', effectiveCwd, err.message);
|
||||
effectiveCwd = process.cwd();
|
||||
}
|
||||
|
||||
const proc = spawn('claude', claudeArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -242,6 +251,12 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSE_STATE_FILE: stateFile || '',
|
||||
// Connect to the existing headed browse server, never start a new one.
|
||||
// BROWSE_PORT tells the CLI which port to check.
|
||||
// BROWSE_NO_AUTOSTART prevents spawning an invisible headless browser
|
||||
// if the headed server is down — fail fast with a clear error instead.
|
||||
BROWSE_PORT: process.env.BROWSE_PORT || '34567',
|
||||
BROWSE_NO_AUTOSTART: '1',
|
||||
// Pin this agent to its tab — prevents cross-tab interference
|
||||
// when multiple agents run simultaneously
|
||||
BROWSE_TAB: String(tid),
|
||||
@@ -258,7 +273,9 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try { handleStreamEvent(JSON.parse(line), tid); } catch {}
|
||||
try { handleStreamEvent(JSON.parse(line), tid); } catch (err: any) {
|
||||
console.error(`[sidebar-agent] Tab ${tid}: Failed to parse stream line:`, line.slice(0, 100), err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -269,7 +286,9 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (buffer.trim()) {
|
||||
try { handleStreamEvent(JSON.parse(buffer), tid); } catch {}
|
||||
try { handleStreamEvent(JSON.parse(buffer), tid); } catch (err: any) {
|
||||
console.error(`[sidebar-agent] Tab ${tid}: Failed to parse final buffer:`, buffer.slice(0, 100), err.message);
|
||||
}
|
||||
}
|
||||
const doneEvent: Record<string, any> = { type: 'agent_done' };
|
||||
if (code !== 0 && stderrBuffer.trim()) {
|
||||
@@ -294,7 +313,9 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
// Timeout (default 300s / 5 min — multi-page tasks need time)
|
||||
const timeoutMs = parseInt(process.env.SIDEBAR_AGENT_TIMEOUT || '300000', 10);
|
||||
setTimeout(() => {
|
||||
try { proc.kill(); } catch {}
|
||||
try { proc.kill(); } catch (killErr: any) {
|
||||
console.warn(`[sidebar-agent] Tab ${tid}: Failed to kill timed-out process:`, killErr.message);
|
||||
}
|
||||
const timeoutMsg = stderrBuffer.trim()
|
||||
? `Timed out after ${timeoutMs / 1000}s\nstderr: ${stderrBuffer.trim().slice(-500)}`
|
||||
: `Timed out after ${timeoutMs / 1000}s`;
|
||||
@@ -311,14 +332,20 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
function countLines(): number {
|
||||
try {
|
||||
return fs.readFileSync(QUEUE, 'utf-8').split('\n').filter(Boolean).length;
|
||||
} catch { return 0; }
|
||||
} catch (err: any) {
|
||||
console.error('[sidebar-agent] Failed to read queue file:', err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function readLine(n: number): string | null {
|
||||
try {
|
||||
const lines = fs.readFileSync(QUEUE, 'utf-8').split('\n').filter(Boolean);
|
||||
return lines[n - 1] || null;
|
||||
} catch { return null; }
|
||||
} catch (err: any) {
|
||||
console.error(`[sidebar-agent] Failed to read queue line ${n}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
@@ -331,7 +358,10 @@ async function poll() {
|
||||
if (!line) continue;
|
||||
|
||||
let entry: any;
|
||||
try { entry = JSON.parse(line); } catch { continue; }
|
||||
try { entry = JSON.parse(line); } catch (err: any) {
|
||||
console.warn(`[sidebar-agent] Skipping malformed queue entry at line ${lastLine}:`, line.slice(0, 80), err.message);
|
||||
continue;
|
||||
}
|
||||
if (!entry.message && !entry.prompt) continue;
|
||||
|
||||
const tid = entry.tabId ?? 0;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GStack Browser</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://api.fontshare.com/v2/css?f[]=satoshi@700,900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--amber-400: #FBBF24;
|
||||
--amber-500: #F59E0B;
|
||||
--zinc-400: #A1A1AA;
|
||||
--zinc-600: #52525B;
|
||||
--zinc-800: #27272A;
|
||||
--surface: #141414;
|
||||
--base: #0C0C0C;
|
||||
--border: #262626;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
body {
|
||||
background: var(--base);
|
||||
color: #e4e4e7;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
background-size: 128px 128px;
|
||||
}
|
||||
.page { width: 100%; max-width: 1060px; padding: 0 40px; }
|
||||
|
||||
/* Sidebar prompt — points RIGHT toward where sidebar opens */
|
||||
.sidebar-prompt {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: opacity 300ms ease-out;
|
||||
}
|
||||
.sidebar-prompt .bubble {
|
||||
background: var(--amber-500);
|
||||
color: #000;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
max-width: 220px;
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sidebar-prompt .arrow-right {
|
||||
font-size: 28px;
|
||||
color: var(--amber-500);
|
||||
animation: nudge 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes nudge {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
50% { transform: translateX(6px); }
|
||||
}
|
||||
.sidebar-prompt.hidden { opacity: 0; pointer-events: none; }
|
||||
|
||||
/* Hero */
|
||||
.hero { margin-bottom: 36px; }
|
||||
.logo-row { display: inline-flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.logo-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; background: var(--amber-500);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(245,158,11,0.4); }
|
||||
50% { opacity: 0.8; box-shadow: 0 0 0 6px rgba(245,158,11,0); }
|
||||
}
|
||||
.logo-text { font-family: 'Satoshi', sans-serif; font-weight: 900; font-size: 28px; color: #fff; letter-spacing: -0.5px; }
|
||||
.tagline { font-size: 15px; color: var(--zinc-400); max-width: 560px; line-height: 1.6; }
|
||||
|
||||
/* Feature cards — 3 columns for 6 cards */
|
||||
.features { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; margin-bottom: 28px; }
|
||||
.feat {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
.feat-title {
|
||||
font-family: 'Satoshi', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: #fff;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.feat p { font-size: 13px; color: var(--zinc-400); line-height: 1.5; }
|
||||
.feat .hl { color: #e4e4e7; font-weight: 500; }
|
||||
.feat code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--amber-400);
|
||||
background: rgba(245,158,11,0.08);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Try it strip */
|
||||
.try-strip {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.try-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--amber-400);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.try-items { display: flex; flex-direction: column; gap: 8px; }
|
||||
.try-item {
|
||||
font-size: 13px;
|
||||
color: var(--zinc-400);
|
||||
line-height: 1.5;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
}
|
||||
.try-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--zinc-600);
|
||||
}
|
||||
.try-item .hl { color: #e4e4e7; font-weight: 500; }
|
||||
|
||||
/* Footer */
|
||||
.footer {}
|
||||
.footer p { font-size: 12px; color: var(--zinc-600); }
|
||||
.footer a { color: var(--zinc-400); text-decoration: none; }
|
||||
.footer a:hover { color: var(--amber-400); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.features { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.features { grid-template-columns: 1fr; }
|
||||
html, body { overflow: auto; }
|
||||
.sidebar-prompt { right: 40px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="sidebar-prompt" id="sidebar-prompt">
|
||||
<div class="bubble">Open the sidebar to get started. Click the puzzle piece icon in the toolbar, then pin gstack browse.</div>
|
||||
<span class="arrow-right">→</span>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<header class="hero">
|
||||
<div class="logo-row">
|
||||
<div class="logo-dot"></div>
|
||||
<span class="logo-text">GStack Browser</span>
|
||||
</div>
|
||||
<p class="tagline">This browser is connected to your Claude Code session. The sidebar is your co-pilot: it can control this window, read pages, edit CSS, and pass everything back to your terminal.</p>
|
||||
</header>
|
||||
|
||||
<div class="features">
|
||||
<div class="feat">
|
||||
<div class="feat-title">Talk to the sidebar</div>
|
||||
<p>The sidebar chat is a Claude instance that <span class="hl">controls this browser</span>. Say "go to my app and check if login works" and watch it navigate, click, fill forms, and report back.</p>
|
||||
</div>
|
||||
<div class="feat">
|
||||
<div class="feat-title">Or use your main agent</div>
|
||||
<p>Your Claude Code terminal <span class="hl">also controls this browser</span>. Run <code>/qa</code>, <code>/design-review</code>, or any skill and watch every action happen here. Two agents, one browser.</p>
|
||||
</div>
|
||||
<div class="feat">
|
||||
<div class="feat-title">Import your cookies</div>
|
||||
<p>Click <span class="hl">🍪 Cookies</span> in the sidebar to import login sessions from Chrome, Arc, or Brave. Browse authenticated pages <span class="hl">without logging in again</span>.</p>
|
||||
</div>
|
||||
<div class="feat">
|
||||
<div class="feat-title">Clean up any page</div>
|
||||
<p>Click <span class="hl">Cleanup</span> in the sidebar. AI identifies overlays, paywalls, cookie banners, and clutter, then <span class="hl">removes them</span>. Articles become readable.</p>
|
||||
</div>
|
||||
<div class="feat">
|
||||
<div class="feat-title">Smart screenshots</div>
|
||||
<p>The <span class="hl">Screenshot</span> button captures a cleaned screenshot and sends it to your Claude Code session as context. "What's wrong with this page?" now has a visual answer.</p>
|
||||
</div>
|
||||
<div class="feat">
|
||||
<div class="feat-title">Modify any page</div>
|
||||
<p>The sidebar can <span class="hl">edit CSS and DOM</span> on any page. "Make the header sticky" or "change the font to Inter." Changes happen live, reported back to your terminal.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="try-strip">
|
||||
<div class="try-title">Try it now</div>
|
||||
<div class="try-items">
|
||||
<div class="try-item">Open the sidebar and type: <span class="hl">"Go to news.ycombinator.com, open the top story, clean up the article, and summarize the key points back to my terminal"</span></div>
|
||||
<div class="try-item">On any article page, click <span class="hl">Cleanup</span> to strip away the noise</div>
|
||||
<div class="try-item">Click <span class="hl">Screenshot</span> to capture the page and send it to your Claude Code session</div>
|
||||
<div class="try-item">Ask the sidebar: <span class="hl">"Inspect the CSS on this page and send the color palette to my terminal"</span></div>
|
||||
<div class="try-item">From your Claude Code terminal: <span class="hl">"Navigate to my app, extract the full CSS design system, and write it to DESIGN.md"</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<p><a href="https://github.com/garrytan/gstack">gstack</a> is open source. Built by <a href="https://x.com/garrytan">Garry Tan</a>.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Hide sidebar prompt ONLY when the sidebar is actually opened.
|
||||
// The content script dispatches 'gstack-extension-ready' when it receives
|
||||
// a 'sidebarOpened' message from the side panel (via background.js).
|
||||
// This means the arrow stays visible until the user actually opens the sidebar.
|
||||
document.addEventListener('gstack-extension-ready', () => {
|
||||
const prompt = document.getElementById('sidebar-prompt');
|
||||
if (prompt) prompt.classList.add('hidden');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -21,13 +21,14 @@ function sliceBetween(source: string, startMarker: string, endMarker: string): s
|
||||
}
|
||||
|
||||
describe('Server auth security', () => {
|
||||
// Test 1: /health response must not leak the auth token
|
||||
test('/health response must not contain token field', () => {
|
||||
// Test 1: /health serves auth token for extension bootstrap (localhost-only, safe)
|
||||
// Previously token was removed from /health, but extension needs it since
|
||||
// .auth.json in the extension dir breaks read-only .app bundles and codesigning.
|
||||
test('/health serves auth token with safety comment', () => {
|
||||
const healthBlock = sliceBetween(SERVER_SRC, "url.pathname === '/health'", "url.pathname === '/refs'");
|
||||
// The old pattern was: token: AUTH_TOKEN
|
||||
// The new pattern should have a comment indicating token was removed
|
||||
expect(healthBlock).not.toContain('token: AUTH_TOKEN');
|
||||
expect(healthBlock).toContain('token removed');
|
||||
expect(healthBlock).toContain('token: AUTH_TOKEN');
|
||||
// Must have a comment explaining why this is safe
|
||||
expect(healthBlock).toContain('localhost-only');
|
||||
});
|
||||
|
||||
// Test 2: /refs endpoint requires auth via validateAuth
|
||||
|
||||
@@ -86,9 +86,11 @@ describe('Sidebar prompt injection defense', () => {
|
||||
|
||||
// --- Model Selection ---
|
||||
|
||||
test('default model is opus', () => {
|
||||
// The args array should include --model opus
|
||||
expect(SERVER_SRC).toContain("'--model', 'opus'");
|
||||
test('model routing defaults to opus for analysis tasks', () => {
|
||||
// pickSidebarModel returns opus for ambiguous/analysis messages
|
||||
expect(SERVER_SRC).toContain("return 'opus'");
|
||||
// spawnClaude uses the model router
|
||||
expect(SERVER_SRC).toContain("'--model', model");
|
||||
});
|
||||
|
||||
// --- Trust Boundary ---
|
||||
|
||||
@@ -165,8 +165,10 @@ describe('sidebar JS (sidepanel.js)', () => {
|
||||
expect(js).toContain("data.agentStatus !== 'processing'");
|
||||
});
|
||||
|
||||
test('orphaned thinking cleanup adds (session ended) notice', () => {
|
||||
expect(js).toContain('(session ended)');
|
||||
test('orphaned thinking cleanup removes thinking dots silently', () => {
|
||||
// Thinking dots are removed when agent is idle — no "(session ended)"
|
||||
// notice, which was removed as noisy false-positive UX
|
||||
expect(js).toContain('thinking.remove()');
|
||||
});
|
||||
|
||||
test('sendMessage renders user bubble + thinking dots optimistically', () => {
|
||||
@@ -296,7 +298,7 @@ describe('TTFO latency chain', () => {
|
||||
test('stopAgent also calls stopFastPoll', () => {
|
||||
const stopFn = js.slice(
|
||||
js.indexOf('async function stopAgent()'),
|
||||
js.indexOf('async function stopAgent()') + 800,
|
||||
js.indexOf('async function stopAgent()') + 1000,
|
||||
);
|
||||
expect(stopFn).toContain('stopFastPoll');
|
||||
});
|
||||
@@ -989,12 +991,17 @@ describe('sidebar agent conciseness + no focus stealing', () => {
|
||||
expect(promptSection).toContain('Do NOT keep exploring');
|
||||
});
|
||||
|
||||
test('sidebar agent uses opus (not sonnet) for prompt injection resistance', () => {
|
||||
test('sidebar agent auto-routes model based on message type', () => {
|
||||
// Model router exists and defaults to opus for analysis tasks
|
||||
expect(serverSrc).toContain('function pickSidebarModel(');
|
||||
expect(serverSrc).toContain("return 'opus'");
|
||||
expect(serverSrc).toContain("return 'sonnet'");
|
||||
// spawnClaude uses the router, not a hardcoded model
|
||||
const spawnFn = serverSrc.slice(
|
||||
serverSrc.indexOf('function spawnClaude('),
|
||||
serverSrc.indexOf('\nfunction ', serverSrc.indexOf('function spawnClaude(') + 1),
|
||||
);
|
||||
expect(spawnFn).toContain("'opus'");
|
||||
expect(spawnFn).toContain('pickSidebarModel(userMessage)');
|
||||
});
|
||||
|
||||
test('switchTab has bringToFront option', () => {
|
||||
@@ -1192,3 +1199,471 @@ describe('LLM-based cleanup (smart agent cleanup)', () => {
|
||||
expect(wcSrc).toContain("role') === 'navigation'");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Welcome page + sidebar auto-open ────────────────────────────
|
||||
|
||||
describe('welcome page', () => {
|
||||
const welcomePath = path.join(ROOT, 'src', 'welcome.html');
|
||||
const welcomeExists = fs.existsSync(welcomePath);
|
||||
const welcomeSrc = welcomeExists ? fs.readFileSync(welcomePath, 'utf-8') : '';
|
||||
|
||||
test('welcome.html exists in browse/src/', () => {
|
||||
expect(welcomeExists).toBe(true);
|
||||
});
|
||||
|
||||
test('welcome page has GStack Browser branding', () => {
|
||||
expect(welcomeSrc).toContain('GStack Browser');
|
||||
});
|
||||
|
||||
test('welcome page has extension-ready listener to hide prompt', () => {
|
||||
expect(welcomeSrc).toContain('gstack-extension-ready');
|
||||
expect(welcomeSrc).toContain('sidebar-prompt');
|
||||
});
|
||||
|
||||
test('welcome page points RIGHT toward sidebar (not UP at toolbar)', () => {
|
||||
// Up arrow can never align with browser chrome. Right arrow always
|
||||
// points toward the sidebar area regardless of window size.
|
||||
expect(welcomeSrc).not.toContain('arrow-up');
|
||||
expect(welcomeSrc).toContain('arrow-right');
|
||||
});
|
||||
|
||||
test('welcome page has left-aligned text (no center-align on headings)', () => {
|
||||
// User preference: always left-align, never center
|
||||
expect(welcomeSrc).not.toMatch(/text-align:\s*center/);
|
||||
});
|
||||
|
||||
test('welcome page uses dark theme', () => {
|
||||
expect(welcomeSrc).toContain('#0C0C0C'); // --base (near-black)
|
||||
expect(welcomeSrc).toContain('#141414'); // --surface (card bg)
|
||||
});
|
||||
});
|
||||
|
||||
describe('server /welcome endpoint', () => {
|
||||
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
|
||||
|
||||
test('/welcome endpoint exists in server.ts', () => {
|
||||
expect(serverSrc).toContain("url.pathname === '/welcome'");
|
||||
});
|
||||
|
||||
test('/welcome serves HTML content type', () => {
|
||||
const welcomeSection = serverSrc.slice(
|
||||
serverSrc.indexOf("url.pathname === '/welcome'"),
|
||||
serverSrc.indexOf("url.pathname === '/health'"),
|
||||
);
|
||||
expect(welcomeSection).toContain("'Content-Type': 'text/html");
|
||||
});
|
||||
|
||||
test('/welcome redirects to about:blank if no welcome file found', () => {
|
||||
const welcomeSection = serverSrc.slice(
|
||||
serverSrc.indexOf("url.pathname === '/welcome'"),
|
||||
serverSrc.indexOf("url.pathname === '/health'"),
|
||||
);
|
||||
expect(welcomeSection).toContain('302');
|
||||
expect(welcomeSection).toContain('about:blank');
|
||||
});
|
||||
});
|
||||
|
||||
describe('headed launch navigates to welcome page', () => {
|
||||
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
|
||||
|
||||
test('server navigates to /welcome after startup in headed mode', () => {
|
||||
// Navigation must happen AFTER Bun.serve() starts (not during launchHeaded)
|
||||
// because the HTTP server needs to be listening before the browser requests /welcome
|
||||
const afterServe = serverSrc.slice(serverSrc.indexOf('Bun.serve('));
|
||||
expect(afterServe).toContain('/welcome');
|
||||
expect(afterServe).toContain("getConnectionMode() === 'headed'");
|
||||
});
|
||||
|
||||
test('welcome navigation does NOT happen in browser-manager (too early)', () => {
|
||||
const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8');
|
||||
// browser-manager.ts should NOT navigate to /welcome because the server
|
||||
// isn't listening yet when launchHeaded() runs
|
||||
const launchHeadedSection = bmSrc.slice(
|
||||
bmSrc.indexOf('async launchHeaded('),
|
||||
bmSrc.indexOf('// Browser disconnect handler'),
|
||||
);
|
||||
expect(launchHeadedSection).not.toContain('/welcome');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidebar auto-open (background.js)', () => {
|
||||
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
|
||||
|
||||
test('autoOpenSidePanel function exists with retry logic', () => {
|
||||
expect(bgSrc).toContain('async function autoOpenSidePanel');
|
||||
expect(bgSrc).toContain('attempt < 5');
|
||||
});
|
||||
|
||||
test('auto-open fires on install AND on every service worker startup', () => {
|
||||
// onInstalled fires on first install / extension update
|
||||
expect(bgSrc).toContain('chrome.runtime.onInstalled.addListener');
|
||||
expect(bgSrc).toContain('autoOpenSidePanel()');
|
||||
// Top-level call fires on every service worker startup
|
||||
const topLevelCalls = bgSrc.match(/^autoOpenSidePanel\(\)/gm);
|
||||
expect(topLevelCalls).not.toBeNull();
|
||||
expect(topLevelCalls!.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('retry uses backoff delays (not fixed interval)', () => {
|
||||
expect(bgSrc).toContain('500');
|
||||
expect(bgSrc).toContain('1000');
|
||||
expect(bgSrc).toContain('2000');
|
||||
expect(bgSrc).toContain('3000');
|
||||
expect(bgSrc).toContain('5000');
|
||||
});
|
||||
|
||||
test('auto-open uses chrome.sidePanel.open with windowId', () => {
|
||||
expect(bgSrc).toContain('chrome.sidePanel.open');
|
||||
expect(bgSrc).toContain('windowId');
|
||||
});
|
||||
|
||||
test('auto-open logs success and failure for debugging', () => {
|
||||
expect(bgSrc).toContain('Side panel opened on attempt');
|
||||
expect(bgSrc).toContain('Side panel auto-open failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidebar arrow hint hide flow (4-step signal chain)', () => {
|
||||
// The arrow hint on the welcome page should ONLY hide when the sidebar
|
||||
// is actually opened, not when the extension content script loads.
|
||||
//
|
||||
// Signal flow:
|
||||
// 1. sidepanel.js connects → sends { type: 'sidebarOpened' } to background
|
||||
// 2. background.js receives → relays to active tab's content script
|
||||
// 3. content.js receives 'sidebarOpened' → dispatches 'gstack-extension-ready'
|
||||
// 4. welcome.html listens for 'gstack-extension-ready' → hides arrow
|
||||
//
|
||||
const contentSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'content.js'), 'utf-8');
|
||||
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
|
||||
const spSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
||||
const welcomeSrc = fs.readFileSync(path.join(ROOT, 'src', 'welcome.html'), 'utf-8');
|
||||
|
||||
// Step 1: sidepanel sends sidebarOpened when connected
|
||||
test('step 1: sidepanel sends sidebarOpened message on connect', () => {
|
||||
expect(spSrc).toContain("{ type: 'sidebarOpened' }");
|
||||
// Should be in updateConnection, after setConnState('connected')
|
||||
const connectFn = spSrc.slice(
|
||||
spSrc.indexOf('function updateConnection('),
|
||||
spSrc.indexOf('function updateConnection(') + 800,
|
||||
);
|
||||
expect(connectFn).toContain('sidebarOpened');
|
||||
});
|
||||
|
||||
// Step 2: background.js accepts and relays sidebarOpened
|
||||
test('step 2: background.js allows sidebarOpened message type', () => {
|
||||
expect(bgSrc).toContain("'sidebarOpened'");
|
||||
// Must be in ALLOWED_TYPES
|
||||
const allowedBlock = bgSrc.slice(
|
||||
bgSrc.indexOf('ALLOWED_TYPES'),
|
||||
bgSrc.indexOf('ALLOWED_TYPES') + 300,
|
||||
);
|
||||
expect(allowedBlock).toContain('sidebarOpened');
|
||||
});
|
||||
|
||||
test('step 2: background.js relays sidebarOpened to active tab content script', () => {
|
||||
expect(bgSrc).toContain("msg.type === 'sidebarOpened'");
|
||||
// Should send to active tab via chrome.tabs.sendMessage
|
||||
const handler = bgSrc.slice(
|
||||
bgSrc.indexOf("msg.type === 'sidebarOpened'"),
|
||||
bgSrc.indexOf("msg.type === 'sidebarOpened'") + 400,
|
||||
);
|
||||
expect(handler).toContain('chrome.tabs.sendMessage');
|
||||
expect(handler).toContain("{ type: 'sidebarOpened' }");
|
||||
});
|
||||
|
||||
// Step 3: content.js fires gstack-extension-ready ONLY on sidebarOpened
|
||||
test('step 3: content.js dispatches extension-ready on sidebarOpened message', () => {
|
||||
expect(contentSrc).toContain("msg.type === 'sidebarOpened'");
|
||||
expect(contentSrc).toContain("new CustomEvent('gstack-extension-ready')");
|
||||
});
|
||||
|
||||
test('step 3: content.js does NOT auto-fire extension-ready on load', () => {
|
||||
// The old pattern was: fire immediately when content script loads.
|
||||
// Now it should only fire when sidebarOpened message arrives.
|
||||
// Check there's no top-level dispatchEvent outside the message handler.
|
||||
const beforeListener = contentSrc.slice(0, contentSrc.indexOf('chrome.runtime.onMessage'));
|
||||
expect(beforeListener).not.toContain("dispatchEvent(new CustomEvent('gstack-extension-ready'))");
|
||||
});
|
||||
|
||||
// Step 4: welcome page hides arrow on gstack-extension-ready
|
||||
test('step 4: welcome page hides arrow on gstack-extension-ready event', () => {
|
||||
expect(welcomeSrc).toContain("'gstack-extension-ready'");
|
||||
expect(welcomeSrc).toContain("classList.add('hidden')");
|
||||
});
|
||||
|
||||
test('step 4: welcome page does NOT auto-hide via status pill polling', () => {
|
||||
// The old fallback (checkPill/gstack-status-pill) would hide the arrow
|
||||
// as soon as the content script injected the pill, even without sidebar open.
|
||||
expect(welcomeSrc).not.toContain('checkPill');
|
||||
expect(welcomeSrc).not.toContain('gstack-status-pill');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidebar auth race prevention', () => {
|
||||
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
|
||||
const spSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
||||
|
||||
test('getPort response includes authToken (not just port + connected)', () => {
|
||||
// The auth race: sidepanel calls getPort, gets {port, connected} but no token.
|
||||
// All subsequent requests fail 401. Token must be in the getPort response.
|
||||
const getPortHandler = bgSrc.slice(
|
||||
bgSrc.indexOf("msg.type === 'getPort'"),
|
||||
bgSrc.indexOf("msg.type === 'setPort'"),
|
||||
);
|
||||
expect(getPortHandler).toContain('token: authToken');
|
||||
});
|
||||
|
||||
test('tryConnect uses token from getPort response', () => {
|
||||
// Sidepanel must pass resp.token to updateConnection, not null
|
||||
const start = spSrc.indexOf('function tryConnect()');
|
||||
const end = spSrc.indexOf('\ntryConnect();', start); // top-level call after the function
|
||||
const tryConnectFn = spSrc.slice(start, end);
|
||||
expect(tryConnectFn).toContain('resp.token');
|
||||
expect(tryConnectFn).not.toContain('updateConnection(url, null)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('startup health check fast-retry', () => {
|
||||
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
|
||||
|
||||
test('initial health check retries every 1s (not 10s)', () => {
|
||||
// The server may not be listening when the extension starts because
|
||||
// Chromium launches before Bun.serve(). A 10s gap means the user
|
||||
// stares at "Connecting..." for 10 seconds. 1s retry fixes this.
|
||||
expect(bgSrc).toContain('startupAttempts');
|
||||
expect(bgSrc).toContain('setInterval(async ()');
|
||||
// Fast retry uses 1000ms, not the 10000ms slow poll
|
||||
expect(bgSrc).toContain('}, 1000);');
|
||||
});
|
||||
|
||||
test('startup retry stops after connection or max attempts', () => {
|
||||
expect(bgSrc).toContain('isConnected || startupAttempts >= 15');
|
||||
expect(bgSrc).toContain('clearInterval(startupCheck)');
|
||||
});
|
||||
|
||||
test('slow 10s polling only starts after startup phase completes', () => {
|
||||
expect(bgSrc).toContain('if (!healthInterval)');
|
||||
expect(bgSrc).toContain('setInterval(checkHealth, 10000)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidebar debug visibility when stuck', () => {
|
||||
const spSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
||||
|
||||
test('connection state machine has a dead state with user-visible message', () => {
|
||||
expect(spSrc).toContain("'dead'");
|
||||
expect(spSrc).toContain('MAX_RECONNECT_ATTEMPTS');
|
||||
});
|
||||
|
||||
test('reconnect attempt counter is visible in the UI', () => {
|
||||
// The banner should show attempt count so user knows something is happening
|
||||
expect(spSrc).toContain('reconnectAttempts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BROWSE_NO_AUTOSTART (sidebar headless prevention)', () => {
|
||||
const cliSrc = fs.readFileSync(path.join(ROOT, 'src', 'cli.ts'), 'utf-8');
|
||||
const agentSrc = fs.readFileSync(path.join(ROOT, 'src', 'sidebar-agent.ts'), 'utf-8');
|
||||
|
||||
test('cli.ts checks BROWSE_NO_AUTOSTART before starting a new server', () => {
|
||||
// ensureServer must check this env var BEFORE calling startServer()
|
||||
const ensureServerFn = cliSrc.slice(
|
||||
cliSrc.indexOf('async function ensureServer()'),
|
||||
cliSrc.indexOf('async function startServer()'),
|
||||
);
|
||||
expect(ensureServerFn).toContain('BROWSE_NO_AUTOSTART');
|
||||
expect(ensureServerFn).toContain('process.exit(1)');
|
||||
});
|
||||
|
||||
test('cli.ts shows actionable error message when BROWSE_NO_AUTOSTART blocks', () => {
|
||||
expect(cliSrc).toContain('/open-gstack-browser');
|
||||
expect(cliSrc).toContain('BROWSE_NO_AUTOSTART is set');
|
||||
});
|
||||
|
||||
test('sidebar-agent.ts sets BROWSE_NO_AUTOSTART=1', () => {
|
||||
expect(agentSrc).toContain("BROWSE_NO_AUTOSTART: '1'");
|
||||
});
|
||||
|
||||
test('sidebar-agent.ts sets BROWSE_PORT for headed server reuse', () => {
|
||||
expect(agentSrc).toContain('BROWSE_PORT');
|
||||
});
|
||||
|
||||
test('BROWSE_NO_AUTOSTART check happens before lock acquisition', () => {
|
||||
// The guard must be BEFORE the lock acquisition. If it's after,
|
||||
// we'd acquire a lock and then exit, leaving a stale lock file.
|
||||
const ensureServerStart = cliSrc.indexOf('async function ensureServer()');
|
||||
const noAutoStart = cliSrc.indexOf('BROWSE_NO_AUTOSTART', ensureServerStart);
|
||||
const lockAcquisition = cliSrc.indexOf('Acquire lock', ensureServerStart);
|
||||
expect(noAutoStart).toBeGreaterThan(0);
|
||||
expect(lockAcquisition).toBeGreaterThan(0);
|
||||
expect(noAutoStart).toBeLessThan(lockAcquisition);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tool-result file filtering (sidebar-agent.ts) ──────────────
|
||||
|
||||
describe('sidebar-agent hides internal tool-result reads', () => {
|
||||
const agentSrc = fs.readFileSync(path.join(ROOT, 'src', 'sidebar-agent.ts'), 'utf-8');
|
||||
|
||||
test('describeToolCall returns empty for tool-results paths', () => {
|
||||
expect(agentSrc).toContain("input.file_path.includes('/tool-results/')");
|
||||
});
|
||||
|
||||
test('describeToolCall returns empty for .claude/projects paths', () => {
|
||||
expect(agentSrc).toContain("input.file_path.includes('/.claude/projects/')");
|
||||
});
|
||||
|
||||
test('empty description causes early return (no event sent)', () => {
|
||||
// describeToolCall returns '' for internal reads, which means
|
||||
// summarizeToolInput returns '', which means event.input is ''
|
||||
const readHandler = agentSrc.slice(
|
||||
agentSrc.indexOf("if (tool === 'Read'"),
|
||||
agentSrc.indexOf("if (tool === 'Edit'"),
|
||||
);
|
||||
expect(readHandler).toContain("return ''");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sidebar skips empty tool_use entries (sidepanel.js) ────────
|
||||
|
||||
describe('sidebar skips empty tool_use descriptions', () => {
|
||||
const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
||||
|
||||
test('tool_use with no input returns early', () => {
|
||||
const toolUseHandler = js.slice(
|
||||
js.indexOf("entry.type === 'tool_use'"),
|
||||
js.indexOf("entry.type === 'tool_use'") + 400,
|
||||
);
|
||||
expect(toolUseHandler).toContain("if (!toolInput) return");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tool calls collapse into "See reasoning" on agent_done ─────
|
||||
|
||||
describe('tool calls collapse into reasoning disclosure', () => {
|
||||
const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
||||
const css = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8');
|
||||
|
||||
test('agent_done wraps tool calls in <details> element', () => {
|
||||
const doneHandler = js.slice(
|
||||
js.indexOf("entry.type === 'agent_done'"),
|
||||
js.indexOf("entry.type === 'agent_done'") + 1200,
|
||||
);
|
||||
expect(doneHandler).toContain("createElement('details')");
|
||||
expect(doneHandler).toContain('agent-reasoning');
|
||||
});
|
||||
|
||||
test('disclosure summary shows step count', () => {
|
||||
const doneHandler = js.slice(
|
||||
js.indexOf("entry.type === 'agent_done'"),
|
||||
js.indexOf("entry.type === 'agent_done'") + 1200,
|
||||
);
|
||||
expect(doneHandler).toContain('See reasoning');
|
||||
expect(doneHandler).toContain('tools.length');
|
||||
});
|
||||
|
||||
test('disclosure inserts before text response', () => {
|
||||
const doneHandler = js.slice(
|
||||
js.indexOf("entry.type === 'agent_done'"),
|
||||
js.indexOf("entry.type === 'agent_done'") + 1200,
|
||||
);
|
||||
// Tool calls should appear before the text answer, not after
|
||||
expect(doneHandler).toContain("querySelector('.agent-text')");
|
||||
expect(doneHandler).toContain('insertBefore(details, textEl)');
|
||||
});
|
||||
|
||||
test('CSS styles the reasoning disclosure', () => {
|
||||
expect(css).toContain('.agent-reasoning');
|
||||
expect(css).toContain('.agent-reasoning summary');
|
||||
// Starts collapsed (no [open] by default)
|
||||
expect(css).toContain('.agent-reasoning[open]');
|
||||
});
|
||||
|
||||
test('disclosure uses custom triangle markers', () => {
|
||||
// No default list-style, custom ▶/▼ via ::before
|
||||
expect(css).toContain('list-style: none');
|
||||
expect(css).toMatch(/agent-reasoning summary::before/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Idle timeout disabled in headed mode (server.ts) ───────────
|
||||
|
||||
describe('idle timeout behavior (server.ts)', () => {
|
||||
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
|
||||
|
||||
test('idle check skips in headed mode', () => {
|
||||
const idleCheck = serverSrc.slice(
|
||||
serverSrc.indexOf('idleCheckInterval'),
|
||||
serverSrc.indexOf('idleCheckInterval') + 300,
|
||||
);
|
||||
expect(idleCheck).toContain("=== 'headed'");
|
||||
expect(idleCheck).toContain('return');
|
||||
});
|
||||
|
||||
test('sidebar-command resets idle timer', () => {
|
||||
const sidebarCmd = serverSrc.slice(
|
||||
serverSrc.indexOf("url.pathname === '/sidebar-command'"),
|
||||
serverSrc.indexOf("url.pathname === '/sidebar-command'") + 300,
|
||||
);
|
||||
expect(sidebarCmd).toContain('resetIdleTimer');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Shutdown kills sidebar-agent daemon (server.ts) ────────────
|
||||
|
||||
describe('shutdown cleanup (server.ts)', () => {
|
||||
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
|
||||
|
||||
test('shutdown kills sidebar-agent daemon process', () => {
|
||||
const shutdownFn = serverSrc.slice(
|
||||
serverSrc.indexOf('async function shutdown()'),
|
||||
serverSrc.indexOf('async function shutdown()') + 800,
|
||||
);
|
||||
expect(shutdownFn).toContain('sidebar-agent');
|
||||
expect(shutdownFn).toContain('pkill');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cookie button in sidebar footer ────────────────────────────
|
||||
|
||||
describe('cookie import button (sidebar)', () => {
|
||||
const html = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.html'), 'utf-8');
|
||||
const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
||||
|
||||
test('quick actions toolbar has cookies button', () => {
|
||||
expect(html).toContain('id="chat-cookies-btn"');
|
||||
expect(html).toContain('Cookies');
|
||||
});
|
||||
|
||||
test('cookies button navigates to cookie-picker', () => {
|
||||
expect(js).toContain("'chat-cookies-btn'");
|
||||
expect(js).toContain('cookie-picker');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Model routing (server.ts) ──────────────────────────────────
|
||||
|
||||
describe('sidebar model routing (server.ts)', () => {
|
||||
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
|
||||
|
||||
test('pickSidebarModel routes actions to sonnet', () => {
|
||||
expect(serverSrc).toContain("return 'sonnet'");
|
||||
});
|
||||
|
||||
test('pickSidebarModel routes analysis to opus', () => {
|
||||
expect(serverSrc).toContain("return 'opus'");
|
||||
});
|
||||
|
||||
test('analysis words override action verbs', () => {
|
||||
// ANALYSIS_WORDS check comes before ACTION_PATTERNS
|
||||
const routerFn = serverSrc.slice(
|
||||
serverSrc.indexOf('function pickSidebarModel('),
|
||||
serverSrc.indexOf('function pickSidebarModel(') + 600,
|
||||
);
|
||||
const analysisCheck = routerFn.indexOf('ANALYSIS_WORDS');
|
||||
const actionCheck = routerFn.indexOf('ACTION_PATTERNS');
|
||||
expect(analysisCheck).toBeGreaterThan(0);
|
||||
expect(actionCheck).toBeGreaterThan(0);
|
||||
expect(analysisCheck).toBeLessThan(actionCheck);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Welcome page E2E test — verifies the sidebar arrow hint and key elements
|
||||
* render correctly when the welcome page is served via HTTP.
|
||||
*
|
||||
* Spins up a real Bun.serve, fetches the HTML, and parses it to verify
|
||||
* the sidebar prompt arrow, feature cards, and branding are present.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const WELCOME_PATH = path.join(import.meta.dir, '../src/welcome.html');
|
||||
const welcomeHtml = fs.readFileSync(WELCOME_PATH, 'utf-8');
|
||||
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(() => {
|
||||
// Serve the welcome page exactly as the browse server does
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
fetch() {
|
||||
return new Response(welcomeHtml, {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
},
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server?.stop();
|
||||
});
|
||||
|
||||
describe('welcome page served via HTTP', () => {
|
||||
let html: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const resp = await fetch(baseUrl);
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.headers.get('content-type')).toContain('text/html');
|
||||
html = await resp.text();
|
||||
});
|
||||
|
||||
// ─── Sidebar arrow hint (the bug that triggered this test) ────────
|
||||
|
||||
test('sidebar prompt arrow is present and visible', () => {
|
||||
// The arrow element with class "arrow-right" must exist
|
||||
expect(html).toContain('class="arrow-right"');
|
||||
// It should contain the right-arrow character (→ = →)
|
||||
expect(html).toContain('→');
|
||||
});
|
||||
|
||||
test('sidebar prompt container is visible by default (no hidden class)', () => {
|
||||
// The prompt div should NOT have the "hidden" class on initial load
|
||||
expect(html).toContain('id="sidebar-prompt"');
|
||||
// Check it doesn't start hidden
|
||||
expect(html).not.toMatch(/class="sidebar-prompt[^"]*hidden/);
|
||||
});
|
||||
|
||||
test('sidebar prompt has instruction text', () => {
|
||||
expect(html).toContain('Open the sidebar to get started');
|
||||
expect(html).toContain('puzzle piece');
|
||||
});
|
||||
|
||||
test('sidebar prompt is positioned on the right side', () => {
|
||||
// CSS should position it on the right
|
||||
expect(html).toMatch(/\.sidebar-prompt\s*\{[^}]*right:\s*\d+px/);
|
||||
});
|
||||
|
||||
test('arrow has nudge animation', () => {
|
||||
expect(html).toContain('@keyframes nudge');
|
||||
expect(html).toMatch(/\.arrow-right\s*\{[^}]*animation:\s*nudge/);
|
||||
});
|
||||
|
||||
// ─── Branding ─────────────────────────────────────────────────────
|
||||
|
||||
test('has GStack Browser title and branding', () => {
|
||||
expect(html).toContain('<title>GStack Browser</title>');
|
||||
expect(html).toContain('GStack Browser');
|
||||
});
|
||||
|
||||
test('has amber dot logo', () => {
|
||||
expect(html).toContain('class="logo-dot"');
|
||||
expect(html).toContain('class="logo-text"');
|
||||
});
|
||||
|
||||
// ─── Feature cards ────────────────────────────────────────────────
|
||||
|
||||
test('has all six feature cards', () => {
|
||||
expect(html).toContain('Talk to the sidebar');
|
||||
expect(html).toContain('Or use your main agent');
|
||||
expect(html).toContain('Import your cookies');
|
||||
expect(html).toContain('Clean up any page');
|
||||
expect(html).toContain('Smart screenshots');
|
||||
expect(html).toContain('Modify any page');
|
||||
});
|
||||
|
||||
// ─── Try it section ───────────────────────────────────────────────
|
||||
|
||||
test('has try-it section with example prompts', () => {
|
||||
expect(html).toContain('Try it now');
|
||||
expect(html).toContain('news.ycombinator.com');
|
||||
});
|
||||
|
||||
// ─── Extension auto-hide ──────────────────────────────────────────
|
||||
|
||||
test('hides sidebar prompt when extension is detected', () => {
|
||||
// Should listen for the extension-ready event
|
||||
expect(html).toContain("'gstack-extension-ready'");
|
||||
// Should add 'hidden' class to sidebar-prompt
|
||||
expect(html).toContain("classList.add('hidden')");
|
||||
});
|
||||
|
||||
test('does NOT auto-hide based on extension detection alone', () => {
|
||||
// The arrow should only hide when the sidebar actually opens,
|
||||
// not when the content script loads (which happens on every page)
|
||||
expect(html).not.toContain('gstack-status-pill');
|
||||
expect(html).not.toContain('checkPill');
|
||||
});
|
||||
|
||||
// ─── Dark theme ───────────────────────────────────────────────────
|
||||
|
||||
test('uses dark theme colors', () => {
|
||||
expect(html).toContain('--base: #0C0C0C');
|
||||
expect(html).toContain('--surface: #141414');
|
||||
});
|
||||
|
||||
// ─── Left-aligned text ────────────────────────────────────────────
|
||||
|
||||
test('text is left-aligned, not centered', () => {
|
||||
expect(html).not.toMatch(/text-align:\s*center/);
|
||||
});
|
||||
|
||||
// ─── Footer ───────────────────────────────────────────────────────
|
||||
|
||||
test('has footer with attribution', () => {
|
||||
expect(html).toContain('Garry Tan');
|
||||
expect(html).toContain('github.com/garrytan/gstack');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user