mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-16 01:02:13 +08:00
Merge remote-tracking branch 'origin/main' into fix/snapshot-dropdown-interactive
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
browse/src/welcome.html
Normal file
237
browse/src/welcome.html
Normal 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">→</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>
|
||||
@@ -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)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user