mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
2d97ab9931
* feat: browse handoff — headless-to-headed browser switching Add `handoff` and `resume` commands that let users take over a visible Chrome when the headless browser gets stuck (CAPTCHAs, auth walls, MFA). Architecture: launch-first-close-second for safe rollback. State transfer via extracted saveState()/restoreState() helpers (DRY with recreateContext). Auto-handoff hint after 3 consecutive command failures. * test: handoff unit + integration tests (15 tests) Covers saveState/restoreState, failure tracking, edge cases (already headed, resume without handoff), and full integration flow with cookie and tab preservation across headless-to-headed switch. * docs: handoff section in browse template + TODOS update Add User Handoff section to browse/SKILL.md.tmpl with usage examples. Update State Persistence TODO noting saveState/restoreState reusability. * chore: bump version and changelog (v0.7.4) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
/**
|
|
* gstack browse server — persistent Chromium daemon
|
|
*
|
|
* Architecture:
|
|
* Bun.serve HTTP on localhost → routes commands to Playwright
|
|
* Console/network/dialog buffers: CircularBuffer in-memory + async disk flush
|
|
* Chromium crash → server EXITS with clear error (CLI auto-restarts)
|
|
* Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min)
|
|
*
|
|
* State:
|
|
* State file: <project-root>/.gstack/browse.json (set via BROWSE_STATE_FILE env)
|
|
* Log files: <project-root>/.gstack/browse-{console,network,dialog}.log
|
|
* Port: random 10000-60000 (or BROWSE_PORT env for debug override)
|
|
*/
|
|
|
|
import { BrowserManager } from './browser-manager';
|
|
import { handleReadCommand } from './read-commands';
|
|
import { handleWriteCommand } from './write-commands';
|
|
import { handleMetaCommand } from './meta-commands';
|
|
import { handleCookiePickerRoute } from './cookie-picker-routes';
|
|
import { COMMAND_DESCRIPTIONS } from './commands';
|
|
import { SNAPSHOT_FLAGS } from './snapshot';
|
|
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as crypto from 'crypto';
|
|
|
|
// ─── Config ─────────────────────────────────────────────────────
|
|
const config = resolveConfig();
|
|
ensureStateDir(config);
|
|
|
|
// ─── Auth ───────────────────────────────────────────────────────
|
|
const AUTH_TOKEN = crypto.randomUUID();
|
|
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
|
|
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min
|
|
|
|
function validateAuth(req: Request): boolean {
|
|
const header = req.headers.get('authorization');
|
|
return header === `Bearer ${AUTH_TOKEN}`;
|
|
}
|
|
|
|
// ─── Help text (auto-generated from COMMAND_DESCRIPTIONS) ────────
|
|
function generateHelpText(): string {
|
|
// Group commands by category
|
|
const groups = new Map<string, string[]>();
|
|
for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) {
|
|
const display = meta.usage || cmd;
|
|
const list = groups.get(meta.category) || [];
|
|
list.push(display);
|
|
groups.set(meta.category, list);
|
|
}
|
|
|
|
const categoryOrder = [
|
|
'Navigation', 'Reading', 'Interaction', 'Inspection',
|
|
'Visual', 'Snapshot', 'Meta', 'Tabs', 'Server',
|
|
];
|
|
|
|
const lines = ['gstack browse — headless browser for AI agents', '', 'Commands:'];
|
|
for (const cat of categoryOrder) {
|
|
const cmds = groups.get(cat);
|
|
if (!cmds) continue;
|
|
lines.push(` ${(cat + ':').padEnd(15)}${cmds.join(', ')}`);
|
|
}
|
|
|
|
// Snapshot flags from source of truth
|
|
lines.push('');
|
|
lines.push('Snapshot flags:');
|
|
const flagPairs: string[] = [];
|
|
for (const flag of SNAPSHOT_FLAGS) {
|
|
const label = flag.valueHint ? `${flag.short} ${flag.valueHint}` : flag.short;
|
|
flagPairs.push(`${label} ${flag.long}`);
|
|
}
|
|
// Print two flags per line for compact display
|
|
for (let i = 0; i < flagPairs.length; i += 2) {
|
|
const left = flagPairs[i].padEnd(28);
|
|
const right = flagPairs[i + 1] || '';
|
|
lines.push(` ${left}${right}`);
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// ─── Buffer (from buffers.ts) ────────────────────────────────────
|
|
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers';
|
|
export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry };
|
|
|
|
const CONSOLE_LOG_PATH = config.consoleLog;
|
|
const NETWORK_LOG_PATH = config.networkLog;
|
|
const DIALOG_LOG_PATH = config.dialogLog;
|
|
let lastConsoleFlushed = 0;
|
|
let lastNetworkFlushed = 0;
|
|
let lastDialogFlushed = 0;
|
|
let flushInProgress = false;
|
|
|
|
async function flushBuffers() {
|
|
if (flushInProgress) return; // Guard against concurrent flush
|
|
flushInProgress = true;
|
|
|
|
try {
|
|
// Console buffer
|
|
const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed;
|
|
if (newConsoleCount > 0) {
|
|
const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length));
|
|
const lines = entries.map(e =>
|
|
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
|
).join('\n') + '\n';
|
|
fs.appendFileSync(CONSOLE_LOG_PATH, lines);
|
|
lastConsoleFlushed = consoleBuffer.totalAdded;
|
|
}
|
|
|
|
// Network buffer
|
|
const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed;
|
|
if (newNetworkCount > 0) {
|
|
const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length));
|
|
const lines = entries.map(e =>
|
|
`[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
|
).join('\n') + '\n';
|
|
fs.appendFileSync(NETWORK_LOG_PATH, lines);
|
|
lastNetworkFlushed = networkBuffer.totalAdded;
|
|
}
|
|
|
|
// Dialog buffer
|
|
const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed;
|
|
if (newDialogCount > 0) {
|
|
const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length));
|
|
const lines = entries.map(e =>
|
|
`[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`
|
|
).join('\n') + '\n';
|
|
fs.appendFileSync(DIALOG_LOG_PATH, lines);
|
|
lastDialogFlushed = dialogBuffer.totalAdded;
|
|
}
|
|
} catch {
|
|
// Flush failures are non-fatal — buffers are in memory
|
|
} finally {
|
|
flushInProgress = false;
|
|
}
|
|
}
|
|
|
|
// Flush every 1 second
|
|
const flushInterval = setInterval(flushBuffers, 1000);
|
|
|
|
// ─── Idle Timer ────────────────────────────────────────────────
|
|
let lastActivity = Date.now();
|
|
|
|
function resetIdleTimer() {
|
|
lastActivity = Date.now();
|
|
}
|
|
|
|
const idleCheckInterval = setInterval(() => {
|
|
if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
|
|
console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`);
|
|
shutdown();
|
|
}
|
|
}, 60_000);
|
|
|
|
// ─── Command Sets (from commands.ts — single source of truth) ───
|
|
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
|
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
|
|
|
|
// ─── Server ────────────────────────────────────────────────────
|
|
const browserManager = new BrowserManager();
|
|
let isShuttingDown = false;
|
|
|
|
// Find port: explicit BROWSE_PORT, or random in 10000-60000
|
|
async function findPort(): Promise<number> {
|
|
// Explicit port override (for debugging)
|
|
if (BROWSE_PORT) {
|
|
try {
|
|
const testServer = Bun.serve({ port: BROWSE_PORT, fetch: () => new Response('ok') });
|
|
testServer.stop();
|
|
return BROWSE_PORT;
|
|
} catch {
|
|
throw new Error(`[browse] Port ${BROWSE_PORT} (from BROWSE_PORT env) is in use`);
|
|
}
|
|
}
|
|
|
|
// Random port with retry
|
|
const MIN_PORT = 10000;
|
|
const MAX_PORT = 60000;
|
|
const MAX_RETRIES = 5;
|
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
const port = MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT));
|
|
try {
|
|
const testServer = Bun.serve({ port, fetch: () => new Response('ok') });
|
|
testServer.stop();
|
|
return port;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
throw new Error(`[browse] No available port after ${MAX_RETRIES} attempts in range ${MIN_PORT}-${MAX_PORT}`);
|
|
}
|
|
|
|
/**
|
|
* Translate Playwright errors into actionable messages for AI agents.
|
|
*/
|
|
function wrapError(err: any): string {
|
|
const msg = err.message || String(err);
|
|
// Timeout errors
|
|
if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) {
|
|
if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) {
|
|
return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`;
|
|
}
|
|
if (msg.includes('page.goto') || msg.includes('Navigation')) {
|
|
return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`;
|
|
}
|
|
return `Operation timed out: ${msg.split('\n')[0]}`;
|
|
}
|
|
// Multiple elements matched
|
|
if (msg.includes('resolved to') && msg.includes('elements')) {
|
|
return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`;
|
|
}
|
|
// Pass through other errors
|
|
return msg;
|
|
}
|
|
|
|
async function handleCommand(body: any): Promise<Response> {
|
|
const { command, args = [] } = body;
|
|
|
|
if (!command) {
|
|
return new Response(JSON.stringify({ error: 'Missing "command" field' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
try {
|
|
let result: string;
|
|
|
|
if (READ_COMMANDS.has(command)) {
|
|
result = await handleReadCommand(command, args, browserManager);
|
|
} else if (WRITE_COMMANDS.has(command)) {
|
|
result = await handleWriteCommand(command, args, browserManager);
|
|
} else if (META_COMMANDS.has(command)) {
|
|
result = await handleMetaCommand(command, args, browserManager, shutdown);
|
|
} else if (command === 'help') {
|
|
const helpText = generateHelpText();
|
|
return new Response(helpText, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
});
|
|
} else {
|
|
return new Response(JSON.stringify({
|
|
error: `Unknown command: ${command}`,
|
|
hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`,
|
|
}), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
browserManager.resetFailures();
|
|
return new Response(result, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
});
|
|
} catch (err: any) {
|
|
browserManager.incrementFailures();
|
|
let errorMsg = wrapError(err);
|
|
const hint = browserManager.getFailureHint();
|
|
if (hint) errorMsg += '\n' + hint;
|
|
return new Response(JSON.stringify({ error: errorMsg }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function shutdown() {
|
|
if (isShuttingDown) return;
|
|
isShuttingDown = true;
|
|
|
|
console.log('[browse] Shutting down...');
|
|
clearInterval(flushInterval);
|
|
clearInterval(idleCheckInterval);
|
|
await flushBuffers(); // Final flush (async now)
|
|
|
|
await browserManager.close();
|
|
|
|
// Clean up state file
|
|
try { fs.unlinkSync(config.stateFile); } catch {}
|
|
|
|
process.exit(0);
|
|
}
|
|
|
|
// Handle signals
|
|
process.on('SIGTERM', shutdown);
|
|
process.on('SIGINT', shutdown);
|
|
|
|
// ─── 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 {}
|
|
|
|
const port = await findPort();
|
|
|
|
// Launch browser
|
|
await browserManager.launch();
|
|
|
|
const startTime = Date.now();
|
|
const server = Bun.serve({
|
|
port,
|
|
hostname: '127.0.0.1',
|
|
fetch: async (req) => {
|
|
resetIdleTimer();
|
|
|
|
const url = new URL(req.url);
|
|
|
|
// Cookie picker routes — no auth required (localhost-only)
|
|
if (url.pathname.startsWith('/cookie-picker')) {
|
|
return handleCookiePickerRoute(url, req, browserManager);
|
|
}
|
|
|
|
// Health check — no auth required (now async)
|
|
if (url.pathname === '/health') {
|
|
const healthy = await browserManager.isHealthy();
|
|
return new Response(JSON.stringify({
|
|
status: healthy ? 'healthy' : 'unhealthy',
|
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
tabs: browserManager.getTabCount(),
|
|
currentUrl: browserManager.getCurrentUrl(),
|
|
}), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// All other endpoints require auth
|
|
if (!validateAuth(req)) {
|
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (url.pathname === '/command' && req.method === 'POST') {
|
|
const body = await req.json();
|
|
return handleCommand(body);
|
|
}
|
|
|
|
return new Response('Not found', { status: 404 });
|
|
},
|
|
});
|
|
|
|
// Write state file (atomic: write .tmp then rename)
|
|
const state = {
|
|
pid: process.pid,
|
|
port,
|
|
token: AUTH_TOKEN,
|
|
startedAt: new Date().toISOString(),
|
|
serverPath: path.resolve(import.meta.dir, 'server.ts'),
|
|
binaryVersion: readVersionHash() || undefined,
|
|
};
|
|
const tmpFile = config.stateFile + '.tmp';
|
|
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
fs.renameSync(tmpFile, config.stateFile);
|
|
|
|
browserManager.serverPort = port;
|
|
console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
|
|
console.log(`[browse] State file: ${config.stateFile}`);
|
|
console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
|
|
}
|
|
|
|
start().catch((err) => {
|
|
console.error(`[browse] Failed to start: ${err.message}`);
|
|
process.exit(1);
|
|
});
|