Merge remote-tracking branch 'origin/main' into fix/snapshot-dropdown-interactive

This commit is contained in:
gstack
2026-04-06 03:35:47 +00:00
136 changed files with 19203 additions and 620 deletions
+35
View File
@@ -82,6 +82,8 @@ fi
_ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false")
echo "HAS_ROUTING: $_HAS_ROUTING"
echo "ROUTING_DECLINED: $_ROUTING_DECLINED"
# Detect spawned session (OpenClaw or other orchestrator)
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
```
If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills AND do not
@@ -208,6 +210,13 @@ Say "No problem. You can add routing rules later by running `gstack-config set r
This only happens once per project. If `HAS_ROUTING` is `yes` or `ROUTING_DECLINED` is `true`, skip this entirely.
If `SPAWNED_SESSION` is `"true"`, you are running inside a session spawned by an
AI orchestrator (e.g., OpenClaw). In spawned sessions:
- Do NOT use AskUserQuestion for interactive prompts. Auto-choose the recommended option.
- Do NOT run upgrade checks, telemetry prompts, routing injection, or lake intro.
- Focus on completing the task and reporting results via prose output.
- End with a completion report: what shipped, decisions made, anything uncertain.
## Voice
**Tone:** direct, concrete, sharp, never corporate, never academic. Sound like a builder, not a consultant. Name the file, the function, the command. No filler, no throat-clearing.
@@ -311,6 +320,31 @@ artifacts that inform the plan, not code changes:
These are read-only in spirit — they inspect the live site, generate visual artifacts,
or get independent opinions. They do NOT modify project source files.
## Skill Invocation During Plan Mode
If a user invokes a skill during plan mode, that invoked skill workflow takes
precedence over generic plan mode behavior until it finishes or the user explicitly
cancels that skill.
Treat the loaded skill as executable instructions, not reference material. Follow
it step by step. Do not summarize, skip, reorder, or shortcut its steps.
If the skill says to use AskUserQuestion, do that. Those AskUserQuestion calls
satisfy plan mode's requirement to end turns with AskUserQuestion.
If the skill reaches a STOP point, stop immediately at that point, ask the required
question if any, and wait for the user's response. Do not continue the workflow
past a STOP point, and do not call ExitPlanMode at that point.
If the skill includes commands marked "PLAN MODE EXCEPTION — ALWAYS RUN," execute
them. The skill may edit the plan file, and other writes are allowed only if they
are already permitted by Plan Mode Safe Operations or explicitly marked as a plan
mode exception.
Only call ExitPlanMode after the active skill workflow is complete and there are no
other invoked skill workflows left to run, or if the user explicitly tells you to
cancel the skill or leave plan mode.
## Plan Status Footer
When you are in plan mode and about to call ExitPlanMode:
@@ -339,6 +373,7 @@ Then write a `## GSTACK REVIEW REPORT` section to the end of the plan file:
| Codex Review | \`/codex review\` | Independent 2nd opinion | 0 | — | — |
| Eng Review | \`/plan-eng-review\` | Architecture & tests (required) | 0 | — | — |
| Design Review | \`/plan-design-review\` | UI/UX gaps | 0 | — | — |
| DX Review | \`/plan-devex-review\` | Developer experience gaps | 0 | — | — |
**VERDICT:** NO REVIEWS YET — run \`/autoplan\` for full review pipeline, or individual reviews above.
\`\`\`
+143 -19
View File
@@ -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 = () => {
@@ -694,7 +822,15 @@ export class BrowserManager {
this.wirePageEvents(page);
if (saved.url) {
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
// Validate the saved URL before navigating — the state file is user-writable and
// a tampered URL could navigate to cloud metadata endpoints or file:// URIs.
try {
await validateNavigationUrl(saved.url);
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
} catch {
// Invalid URL in saved state — skip navigation, leave blank page
console.log(`[browse] restoreState: skipping unsafe URL: ${saved.url}`);
}
}
if (saved.storage) {
@@ -825,20 +961,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
View File
@@ -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);
}
+1 -1
View File
@@ -79,7 +79,7 @@ export function resolveConfig(
*/
export function ensureStateDir(config: BrowseConfig): void {
try {
fs.mkdirSync(config.stateDir, { recursive: true });
fs.mkdirSync(config.stateDir, { recursive: true, mode: 0o700 });
} catch (err: any) {
if (err.code === 'EACCES') {
throw new Error(`Cannot create state directory ${config.stateDir}: permission denied`);
+7 -8
View File
@@ -81,14 +81,13 @@ export async function handleCookiePickerRoute(
}
// ─── Auth gate: all data/action routes below require Bearer token ───
if (authToken) {
const authHeader = req.headers.get('authorization');
if (!authHeader || authHeader !== `Bearer ${authToken}`) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// Auth is mandatory — if authToken is undefined, reject all requests
const authHeader = req.headers.get('authorization');
if (!authToken || !authHeader || authHeader !== `Bearer ${authToken}`) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// GET /cookie-picker/browsers — list installed browsers
+11
View File
@@ -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">
+225 -52
View File
@@ -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 {
@@ -359,10 +398,10 @@ function createSession(): SidebarSession {
lastActiveAt: new Date().toISOString(),
};
const sessionDir = path.join(SESSIONS_DIR, id);
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(path.join(sessionDir, 'session.json'), JSON.stringify(session, null, 2));
fs.writeFileSync(path.join(sessionDir, 'chat.jsonl'), '');
fs.writeFileSync(path.join(SESSIONS_DIR, 'active.json'), JSON.stringify({ id }));
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(path.join(sessionDir, 'session.json'), JSON.stringify(session, null, 2), { mode: 0o600 });
fs.writeFileSync(path.join(sessionDir, 'chat.jsonl'), '', { mode: 0o600 });
fs.writeFileSync(path.join(SESSIONS_DIR, 'active.json'), JSON.stringify({ id }), { mode: 0o600 });
chatBuffer = [];
chatNextId = 0;
return session;
@@ -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), { mode: 0o600 }); } 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' });
@@ -505,7 +558,7 @@ function spawnClaude(userMessage: string, extensionUrl?: string | null, forTabId
tabId: agentTabId,
});
try {
fs.mkdirSync(gstackDir, { recursive: true });
fs.mkdirSync(gstackDir, { recursive: true, mode: 0o700 });
fs.appendFileSync(agentQueue, entry + '\n');
} catch (err: any) {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: `Failed to queue: ${err.message}` });
@@ -521,13 +574,24 @@ 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;
currentMessage = null;
agentStatus = 'idle';
// Signal sidebar-agent.ts to kill its active claude subprocess.
// sidebar-agent runs in a separate non-compiled Bun process (posix_spawn
// limitation). It polls the kill-signal file and terminates on any write.
const agentQueue = process.env.SIDEBAR_QUEUE_PATH || path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
const killFile = path.join(path.dirname(agentQueue), 'sidebar-agent-kill');
try { fs.writeFileSync(killFile, String(Date.now())); } catch {}
}
// Agent health check — detect hung processes
@@ -550,7 +614,7 @@ function startAgentHealthCheck(): void {
// Initialize session on startup
function initSidebarSession(): void {
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
fs.mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
sidebarSession = loadSession();
if (!sidebarSession) {
sidebarSession = createSession();
@@ -600,8 +664,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 +682,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 +706,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 +794,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 +880,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 +891,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 +925,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 +955,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 +975,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 +986,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 +1018,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 +1055,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 +1093,11 @@ 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. Only returned when the request
// comes from a Chrome extension (Origin: chrome-extension://...).
// Previously served unconditionally, but that leaks the token if the
// server is tunneled to the internet (ngrok, SSH tunnel).
...(req.headers.get('origin')?.startsWith('chrome-extension://') ? { token: AUTH_TOKEN } : {}),
chatEnabled: true,
agent: {
status: agentStatus,
@@ -1020,7 +1159,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 +1169,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 +1180,9 @@ async function start() {
req.signal.addEventListener('abort', () => {
clearInterval(heartbeat);
unsubscribe();
try { controller.close(); } catch {}
try { controller.close(); } catch {
// Expected: stream already closed
}
});
},
});
@@ -1087,12 +1230,12 @@ async function start() {
const tabs = await browserManager.getTabListWithTitles();
return new Response(JSON.stringify({ tabs }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://127.0.0.1' },
});
} catch (err: any) {
return new Response(JSON.stringify({ tabs: [], error: err.message }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://127.0.0.1' },
});
}
}
@@ -1111,7 +1254,7 @@ async function start() {
browserManager.switchTab(tabId);
return new Response(JSON.stringify({ ok: true, activeTab: tabId }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://127.0.0.1' },
});
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
@@ -1133,7 +1276,7 @@ async function start() {
const tabAgentStatus = tabId !== null ? getTabAgentStatus(tabId) : agentStatus;
return new Response(JSON.stringify({ entries, total: chatNextId, agentStatus: tabAgentStatus, activeTabId: activeTab }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://127.0.0.1' },
});
}
@@ -1142,6 +1285,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 +1332,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'), '', { mode: 0o600 }); } 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' } });
}
@@ -1411,8 +1557,14 @@ async function start() {
});
}
// GET /inspector/events — SSE for inspector state changes
// GET /inspector/events — SSE for inspector state changes (auth required)
if (url.pathname === '/inspector/events' && req.method === 'GET') {
const streamToken = url.searchParams.get('token');
if (!validateAuth(req) && streamToken !== AUTH_TOKEN) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401, headers: { 'Content-Type': 'application/json' },
});
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
@@ -1429,7 +1581,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 +1592,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 +1603,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 +1647,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 +1676,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}`);
@@ -1521,8 +1694,8 @@ start().catch((err) => {
// stderr because the server is launched with detached: true, stdio: 'ignore'.
try {
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
fs.mkdirSync(config.stateDir, { recursive: true });
fs.writeFileSync(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`);
fs.mkdirSync(config.stateDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`, { mode: 0o600 });
} catch {
// stateDir may not exist — nothing more we can do
}
+75 -14
View File
@@ -14,6 +14,7 @@ import * as fs from 'fs';
import * as path from 'path';
const QUEUE = process.env.SIDEBAR_QUEUE_PATH || path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
const KILL_FILE = path.join(path.dirname(QUEUE), 'sidebar-agent-kill');
const SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '34567', 10);
const SERVER_URL = `http://127.0.0.1:${SERVER_PORT}`;
const POLL_MS = 200; // 200ms poll — keeps time-to-first-token low
@@ -23,6 +24,10 @@ let lastLine = 0;
let authToken: string | null = null;
// Per-tab processing — each tab can run its own agent concurrently
const processingTabs = new Set<number>();
// Active claude subprocesses — keyed by tabId for targeted kill
const activeProcs = new Map<number, ReturnType<typeof spawn>>();
// Kill-file timestamp last seen — avoids double-kill on same write
let lastKillTs = 0;
// ─── File drop relay ──────────────────────────────────────────
@@ -30,7 +35,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;
}
}
@@ -43,7 +49,7 @@ function writeToInbox(message: string, pageUrl?: string, sessionId?: string): vo
}
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
fs.mkdirSync(inboxDir, { recursive: true });
fs.mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
const now = new Date();
const timestamp = now.toISOString().replace(/:/g, '-');
@@ -59,7 +65,7 @@ function writeToInbox(message: string, pageUrl?: string, sessionId?: string): vo
sidebarSessionId: sessionId || 'unknown',
};
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2));
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2), { mode: 0o600 });
fs.renameSync(tmpFile, finalFile);
console.log(`[sidebar-agent] Wrote inbox message: ${filename}`);
}
@@ -74,7 +80,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 +172,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 +245,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,12 +256,21 @@ 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),
},
});
// Track active procs so kill-file polling can terminate them
activeProcs.set(tid, proc);
proc.stdin.end();
let buffer = '';
@@ -258,7 +281,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);
}
}
});
@@ -268,8 +293,11 @@ async function askClaude(queueEntry: any): Promise<void> {
});
proc.on('close', (code) => {
activeProcs.delete(tid);
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 +322,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 +341,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 +367,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;
@@ -351,10 +390,31 @@ async function poll() {
// ─── Main ────────────────────────────────────────────────────────
function pollKillFile(): void {
try {
const stat = fs.statSync(KILL_FILE);
const mtime = stat.mtimeMs;
if (mtime > lastKillTs) {
lastKillTs = mtime;
if (activeProcs.size > 0) {
console.log(`[sidebar-agent] Kill signal received — terminating ${activeProcs.size} active agent(s)`);
for (const [tid, proc] of activeProcs) {
try { proc.kill('SIGTERM'); } catch {}
setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 2000);
processingTabs.delete(tid);
}
activeProcs.clear();
}
}
} catch {
// Kill file doesn't exist yet — normal state
}
}
async function main() {
const dir = path.dirname(QUEUE);
fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, '');
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, '', { mode: 0o600 });
lastLine = countLines();
await refreshToken();
@@ -364,6 +424,7 @@ async function main() {
console.log(`[sidebar-agent] Browse binary: ${B}`);
setInterval(poll, POLL_MS);
setInterval(pollKillFile, POLL_MS);
}
main().catch(console.error);
+29 -5
View File
@@ -4,8 +4,10 @@
*/
const BLOCKED_METADATA_HOSTS = new Set([
'169.254.169.254', // AWS/GCP/Azure instance metadata
'169.254.169.254', // AWS/GCP/Azure instance metadata (IPv4 link-local)
'fe80::1', // IPv6 link-local — common metadata endpoint alias
'fd00::', // IPv6 unique local (metadata in some cloud setups)
'::ffff:169.254.169.254', // IPv4-mapped IPv6 form of the metadata IP
'metadata.google.internal', // GCP metadata
'metadata.azure.internal', // Azure IMDS
]);
@@ -47,15 +49,37 @@ function isMetadataIp(hostname: string): boolean {
/**
* Resolve a hostname to its IP addresses and check if any resolve to blocked metadata IPs.
* Mitigates DNS rebinding: even if the hostname looks safe, the resolved IP might not be.
*
* Checks both A (IPv4) and AAAA (IPv6) records — an attacker can use AAAA-only DNS to
* bypass IPv4-only checks. Each record family is tried independently; failure of one
* (e.g. no AAAA records exist) is not treated as a rebinding risk.
*/
async function resolvesToBlockedIp(hostname: string): Promise<boolean> {
try {
const dns = await import('node:dns');
const { resolve4 } = dns.promises;
const addresses = await resolve4(hostname);
return addresses.some(addr => BLOCKED_METADATA_HOSTS.has(addr));
const { resolve4, resolve6 } = dns.promises;
// Check IPv4 A records
const v4Check = resolve4(hostname).then(
(addresses) => addresses.some(addr => BLOCKED_METADATA_HOSTS.has(addr)),
() => false, // ENODATA / ENOTFOUND — no A records, not a risk
);
// Check IPv6 AAAA records — the gap that issue #668 identified
const v6Check = resolve6(hostname).then(
(addresses) => addresses.some(addr => {
const normalized = addr.toLowerCase();
return BLOCKED_METADATA_HOSTS.has(normalized) ||
// fe80::/10 is link-local — always block (covers all fe80:: addresses)
normalized.startsWith('fe80:');
}),
() => false, // ENODATA / ENOTFOUND — no AAAA records, not a risk
);
const [v4Blocked, v6Blocked] = await Promise.all([v4Check, v6Check]);
return v4Blocked || v6Blocked;
} catch {
// DNS resolution failed — not a rebinding risk
// Unexpected error — fail open (don't block navigation on DNS infrastructure failure)
return false;
}
}
+237
View File
@@ -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">&#x2192;</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>
+29
View File
@@ -18,10 +18,39 @@ const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
function validateOutputPath(filePath: string): void {
const resolved = path.resolve(filePath);
// Basic containment check using lexical resolution only.
// This catches obvious traversal (../../../etc/passwd) but NOT symlinks.
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir));
if (!isSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
// Symlink check: resolve the real path of the nearest existing ancestor
// directory and re-validate. This closes the symlink bypass where a
// symlink inside /tmp or cwd points outside the safe zone.
//
// We resolve the parent dir (not the file itself — it may not exist yet).
// If the parent doesn't exist either we fall back up the tree.
let dir = path.dirname(resolved);
let realDir: string;
try {
realDir = fs.realpathSync(dir);
} catch {
// Parent doesn't exist — check the grandparent, or skip if inaccessible
try {
realDir = fs.realpathSync(path.dirname(dir));
} catch {
// Can't resolve — fail safe
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
}
const realResolved = path.join(realDir, path.basename(resolved));
const isRealSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realResolved, dir));
if (!isRealSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')} (symlink target blocked)`);
}
}
/**
+7 -6
View File
@@ -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)
// Token is gated on chrome-extension:// Origin header to prevent leaking
// when the server is tunneled to the internet.
test('/health serves auth token only for chrome extension origin', () => {
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('AUTH_TOKEN');
// Must be gated on chrome-extension Origin
expect(healthBlock).toContain('chrome-extension://');
});
// Test 2: /refs endpoint requires auth via validateAuth
+5 -3
View File
@@ -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 ---
+480 -5
View File
@@ -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);
});
});
+143
View File
@@ -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 (→ = &#x2192;)
expect(html).toContain('&#x2192;');
});
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');
});
});