bookworm-smart-assistant/skills/gstack/browse/dist/server-node.mjs

3147 lines
107 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createRequire } from "node:module";
// ── Windows Node.js compatibility (auto-generated) ──
import { fileURLToPath as _ftp } from "node:url";
import { dirname as _dn } from "node:path";
const __browseNodeSrcDir = _dn(_dn(_ftp(import.meta.url))) + "/src";
{ const _r = createRequire(import.meta.url); _r("./bun-polyfill.cjs"); }
// ── end compatibility ──
var __defProp = Object.defineProperty;
var __returnValue = (v) => v;
function __exportSetter(name, newValue) {
this[name] = __returnValue.bind(null, newValue);
}
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: __exportSetter.bind(all, name)
});
};
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
var __require = /* @__PURE__ */ createRequire(import.meta.url);
// browse/src/buffers.ts
class CircularBuffer {
buffer;
head = 0;
_size = 0;
_totalAdded = 0;
capacity;
constructor(capacity) {
this.capacity = capacity;
this.buffer = new Array(capacity);
}
push(entry) {
const index = (this.head + this._size) % this.capacity;
this.buffer[index] = entry;
if (this._size < this.capacity) {
this._size++;
} else {
this.head = (this.head + 1) % this.capacity;
}
this._totalAdded++;
}
toArray() {
const result = [];
for (let i = 0;i < this._size; i++) {
result.push(this.buffer[(this.head + i) % this.capacity]);
}
return result;
}
last(n) {
const count = Math.min(n, this._size);
const result = [];
const start = (this.head + this._size - count) % this.capacity;
for (let i = 0;i < count; i++) {
result.push(this.buffer[(start + i) % this.capacity]);
}
return result;
}
get length() {
return this._size;
}
get totalAdded() {
return this._totalAdded;
}
clear() {
this.head = 0;
this._size = 0;
}
get(index) {
if (index < 0 || index >= this._size)
return;
return this.buffer[(this.head + index) % this.capacity];
}
set(index, entry) {
if (index < 0 || index >= this._size)
return;
this.buffer[(this.head + index) % this.capacity] = entry;
}
}
function addConsoleEntry(entry) {
consoleBuffer.push(entry);
}
function addNetworkEntry(entry) {
networkBuffer.push(entry);
}
function addDialogEntry(entry) {
dialogBuffer.push(entry);
}
var HIGH_WATER_MARK = 50000, consoleBuffer, networkBuffer, dialogBuffer;
var init_buffers = __esm(() => {
consoleBuffer = new CircularBuffer(HIGH_WATER_MARK);
networkBuffer = new CircularBuffer(HIGH_WATER_MARK);
dialogBuffer = new CircularBuffer(HIGH_WATER_MARK);
});
// browse/src/url-validation.ts
function normalizeHostname(hostname) {
let h = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
if (h.endsWith("."))
h = h.slice(0, -1);
return h;
}
function isMetadataIp(hostname) {
try {
const probe = new URL(`http://${hostname}`);
const normalized = probe.hostname;
if (BLOCKED_METADATA_HOSTS.has(normalized))
return true;
if (normalized.endsWith(".") && BLOCKED_METADATA_HOSTS.has(normalized.slice(0, -1)))
return true;
} catch {}
return false;
}
function validateNavigationUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid URL: ${url}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Blocked: scheme "${parsed.protocol}" is not allowed. Only http: and https: URLs are permitted.`);
}
const hostname = normalizeHostname(parsed.hostname.toLowerCase());
if (BLOCKED_METADATA_HOSTS.has(hostname) || isMetadataIp(hostname)) {
throw new Error(`Blocked: ${parsed.hostname} is a cloud metadata endpoint. Access is denied for security.`);
}
}
var BLOCKED_METADATA_HOSTS;
var init_url_validation = __esm(() => {
BLOCKED_METADATA_HOSTS = new Set([
"169.254.169.254",
"fd00::",
"metadata.google.internal"
]);
});
// browse/src/platform.ts
import * as os from "os";
import * as path from "path";
function isPathWithin(resolvedPath, dir) {
return resolvedPath === dir || resolvedPath.startsWith(dir + path.sep);
}
var IS_WINDOWS, TEMP_DIR;
var init_platform = __esm(() => {
IS_WINDOWS = process.platform === "win32";
TEMP_DIR = IS_WINDOWS ? os.tmpdir() : "/tmp";
});
// browse/src/read-commands.ts
var exports_read_commands = {};
__export(exports_read_commands, {
validateReadPath: () => validateReadPath,
handleReadCommand: () => handleReadCommand,
getCleanText: () => getCleanText
});
import * as fs from "fs";
import * as path2 from "path";
function hasAwait(code) {
const stripped = code.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
return /\bawait\b/.test(stripped);
}
function needsBlockWrapper(code) {
const trimmed = code.trim();
if (trimmed.split(`
`).length > 1)
return true;
if (/\b(const|let|var|function|class|return|throw|if|for|while|switch|try)\b/.test(trimmed))
return true;
if (trimmed.includes(";"))
return true;
return false;
}
function wrapForEvaluate(code) {
if (!hasAwait(code))
return code;
const trimmed = code.trim();
return needsBlockWrapper(trimmed) ? `(async()=>{
${code}
})()` : `(async()=>(${trimmed}))()`;
}
function validateReadPath(filePath) {
if (path2.isAbsolute(filePath)) {
const resolved = path2.resolve(filePath);
const isSafe = SAFE_DIRECTORIES.some((dir) => isPathWithin(resolved, dir));
if (!isSafe) {
throw new Error(`Absolute path must be within: ${SAFE_DIRECTORIES.join(", ")}`);
}
}
const normalized = path2.normalize(filePath);
if (normalized.includes("..")) {
throw new Error("Path traversal sequences (..) are not allowed");
}
}
async function getCleanText(page) {
return await page.evaluate(() => {
const body = document.body;
if (!body)
return "";
const clone = body.cloneNode(true);
clone.querySelectorAll("script, style, noscript, svg").forEach((el) => el.remove());
return clone.innerText.split(`
`).map((line) => line.trim()).filter((line) => line.length > 0).join(`
`);
});
}
async function handleReadCommand(command, args, bm) {
const page = bm.getPage();
switch (command) {
case "text": {
return await getCleanText(page);
}
case "html": {
const selector = args[0];
if (selector) {
const resolved = await bm.resolveRef(selector);
if ("locator" in resolved) {
return await resolved.locator.innerHTML({ timeout: 5000 });
}
return await page.innerHTML(resolved.selector);
}
return await page.content();
}
case "links": {
const links = await page.evaluate(() => [...document.querySelectorAll("a[href]")].map((a) => ({
text: a.textContent?.trim().slice(0, 120) || "",
href: a.href
})).filter((l) => l.text && l.href));
return links.map((l) => `${l.text}${l.href}`).join(`
`);
}
case "forms": {
const forms = await page.evaluate(() => {
return [...document.querySelectorAll("form")].map((form, i) => {
const fields = [...form.querySelectorAll("input, select, textarea")].map((el) => {
const input = el;
return {
tag: el.tagName.toLowerCase(),
type: input.type || undefined,
name: input.name || undefined,
id: input.id || undefined,
placeholder: input.placeholder || undefined,
required: input.required || undefined,
value: input.type === "password" ? "[redacted]" : input.value || undefined,
options: el.tagName === "SELECT" ? [...el.options].map((o) => ({ value: o.value, text: o.text })) : undefined
};
});
return {
index: i,
action: form.action || undefined,
method: form.method || "get",
id: form.id || undefined,
fields
};
});
});
return JSON.stringify(forms, null, 2);
}
case "accessibility": {
const snapshot = await page.locator("body").ariaSnapshot();
return snapshot;
}
case "js": {
const expr = args[0];
if (!expr)
throw new Error("Usage: browse js <expression>");
const wrapped = wrapForEvaluate(expr);
const result = await page.evaluate(wrapped);
return typeof result === "object" ? JSON.stringify(result, null, 2) : String(result ?? "");
}
case "eval": {
const filePath = args[0];
if (!filePath)
throw new Error("Usage: browse eval <js-file>");
validateReadPath(filePath);
if (!fs.existsSync(filePath))
throw new Error(`File not found: ${filePath}`);
const code = fs.readFileSync(filePath, "utf-8");
const wrapped = wrapForEvaluate(code);
const result = await page.evaluate(wrapped);
return typeof result === "object" ? JSON.stringify(result, null, 2) : String(result ?? "");
}
case "css": {
const [selector, property] = args;
if (!selector || !property)
throw new Error("Usage: browse css <selector> <property>");
const resolved = await bm.resolveRef(selector);
if ("locator" in resolved) {
const value2 = await resolved.locator.evaluate((el, prop) => getComputedStyle(el).getPropertyValue(prop), property);
return value2;
}
const value = await page.evaluate(([sel, prop]) => {
const el = document.querySelector(sel);
if (!el)
return `Element not found: ${sel}`;
return getComputedStyle(el).getPropertyValue(prop);
}, [resolved.selector, property]);
return value;
}
case "attrs": {
const selector = args[0];
if (!selector)
throw new Error("Usage: browse attrs <selector>");
const resolved = await bm.resolveRef(selector);
if ("locator" in resolved) {
const attrs2 = await resolved.locator.evaluate((el) => {
const result = {};
for (const attr of el.attributes) {
result[attr.name] = attr.value;
}
return result;
});
return JSON.stringify(attrs2, null, 2);
}
const attrs = await page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el)
return `Element not found: ${sel}`;
const result = {};
for (const attr of el.attributes) {
result[attr.name] = attr.value;
}
return result;
}, resolved.selector);
return typeof attrs === "string" ? attrs : JSON.stringify(attrs, null, 2);
}
case "console": {
if (args[0] === "--clear") {
consoleBuffer.clear();
return "Console buffer cleared.";
}
const entries = args[0] === "--errors" ? consoleBuffer.toArray().filter((e) => e.level === "error" || e.level === "warning") : consoleBuffer.toArray();
if (entries.length === 0)
return args[0] === "--errors" ? "(no console errors)" : "(no console messages)";
return entries.map((e) => `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`).join(`
`);
}
case "network": {
if (args[0] === "--clear") {
networkBuffer.clear();
return "Network buffer cleared.";
}
if (networkBuffer.length === 0)
return "(no network requests)";
return networkBuffer.toArray().map((e) => `${e.method} ${e.url}${e.status || "pending"} (${e.duration || "?"}ms, ${e.size || "?"}B)`).join(`
`);
}
case "dialog": {
if (args[0] === "--clear") {
dialogBuffer.clear();
return "Dialog buffer cleared.";
}
if (dialogBuffer.length === 0)
return "(no dialogs captured)";
return dialogBuffer.toArray().map((e) => `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ""}`).join(`
`);
}
case "is": {
const property = args[0];
const selector = args[1];
if (!property || !selector)
throw new Error(`Usage: browse is <property> <selector>
Properties: visible, hidden, enabled, disabled, checked, editable, focused`);
const resolved = await bm.resolveRef(selector);
let locator;
if ("locator" in resolved) {
locator = resolved.locator;
} else {
locator = page.locator(resolved.selector);
}
switch (property) {
case "visible":
return String(await locator.isVisible());
case "hidden":
return String(await locator.isHidden());
case "enabled":
return String(await locator.isEnabled());
case "disabled":
return String(await locator.isDisabled());
case "checked":
return String(await locator.isChecked());
case "editable":
return String(await locator.isEditable());
case "focused": {
const isFocused = await locator.evaluate((el) => el === document.activeElement);
return String(isFocused);
}
default:
throw new Error(`Unknown property: ${property}. Use: visible, hidden, enabled, disabled, checked, editable, focused`);
}
}
case "cookies": {
const cookies = await page.context().cookies();
return JSON.stringify(cookies, null, 2);
}
case "storage": {
if (args[0] === "set" && args[1]) {
const key = args[1];
const value = args[2] || "";
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
return `Set localStorage["${key}"]`;
}
const storage = await page.evaluate(() => ({
localStorage: { ...localStorage },
sessionStorage: { ...sessionStorage }
}));
return JSON.stringify(storage, null, 2);
}
case "perf": {
const timings = await page.evaluate(() => {
const nav = performance.getEntriesByType("navigation")[0];
if (!nav)
return "No navigation timing data available.";
return {
dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
tcp: Math.round(nav.connectEnd - nav.connectStart),
ssl: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0),
ttfb: Math.round(nav.responseStart - nav.requestStart),
download: Math.round(nav.responseEnd - nav.responseStart),
domParse: Math.round(nav.domInteractive - nav.responseEnd),
domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
load: Math.round(nav.loadEventEnd - nav.startTime),
total: Math.round(nav.loadEventEnd - nav.startTime)
};
});
if (typeof timings === "string")
return timings;
return Object.entries(timings).map(([k, v]) => `${k.padEnd(12)} ${v}ms`).join(`
`);
}
default:
throw new Error(`Unknown read command: ${command}`);
}
}
var SAFE_DIRECTORIES;
var init_read_commands = __esm(() => {
init_buffers();
init_platform();
SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
});
// browse/src/cookie-import-browser.ts
const Database = null; // bun:sqlite stubbed on Node
import * as crypto from "crypto";
import * as fs2 from "fs";
import * as path3 from "path";
import * as os2 from "os";
function findInstalledBrowsers() {
const appSupport = path3.join(os2.homedir(), "Library", "Application Support");
return BROWSER_REGISTRY.filter((b) => {
const dbPath = path3.join(appSupport, b.dataDir, "Default", "Cookies");
try {
return fs2.existsSync(dbPath);
} catch {
return false;
}
});
}
function listDomains(browserName, profile = "Default") {
const browser = resolveBrowser(browserName);
const dbPath = getCookieDbPath(browser, profile);
const db = openDb(dbPath, browser.name);
try {
const now = chromiumNow();
const rows = db.query(`SELECT host_key AS domain, COUNT(*) AS count
FROM cookies
WHERE has_expires = 0 OR expires_utc > ?
GROUP BY host_key
ORDER BY count DESC`).all(now);
return { domains: rows, browser: browser.name };
} finally {
db.close();
}
}
async function importCookies(browserName, domains, profile = "Default") {
if (domains.length === 0)
return { cookies: [], count: 0, failed: 0, domainCounts: {} };
const browser = resolveBrowser(browserName);
const derivedKey = await getDerivedKey(browser);
const dbPath = getCookieDbPath(browser, profile);
const db = openDb(dbPath, browser.name);
try {
const now = chromiumNow();
const placeholders = domains.map(() => "?").join(",");
const rows = db.query(`SELECT host_key, name, value, encrypted_value, path, expires_utc,
is_secure, is_httponly, has_expires, samesite
FROM cookies
WHERE host_key IN (${placeholders})
AND (has_expires = 0 OR expires_utc > ?)
ORDER BY host_key, name`).all(...domains, now);
const cookies = [];
let failed = 0;
const domainCounts = {};
for (const row of rows) {
try {
const value = decryptCookieValue(row, derivedKey);
const cookie = toPlaywrightCookie(row, value);
cookies.push(cookie);
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
} catch {
failed++;
}
}
return { cookies, count: cookies.length, failed, domainCounts };
} finally {
db.close();
}
}
function resolveBrowser(nameOrAlias) {
const needle = nameOrAlias.toLowerCase().trim();
const found = BROWSER_REGISTRY.find((b) => b.aliases.includes(needle) || b.name.toLowerCase() === needle);
if (!found) {
const supported = BROWSER_REGISTRY.flatMap((b) => b.aliases).join(", ");
throw new CookieImportError(`Unknown browser '${nameOrAlias}'. Supported: ${supported}`, "unknown_browser");
}
return found;
}
function validateProfile(profile) {
if (/[/\\]|\.\./.test(profile) || /[\x00-\x1f]/.test(profile)) {
throw new CookieImportError(`Invalid profile name: '${profile}'`, "bad_request");
}
}
function getCookieDbPath(browser, profile) {
validateProfile(profile);
const appSupport = path3.join(os2.homedir(), "Library", "Application Support");
const dbPath = path3.join(appSupport, browser.dataDir, profile, "Cookies");
if (!fs2.existsSync(dbPath)) {
throw new CookieImportError(`${browser.name} is not installed (no cookie database at ${dbPath})`, "not_installed");
}
return dbPath;
}
function openDb(dbPath, browserName) {
try {
return new Database(dbPath, { readonly: true });
} catch (err) {
if (err.message?.includes("SQLITE_BUSY") || err.message?.includes("database is locked")) {
return openDbFromCopy(dbPath, browserName);
}
if (err.message?.includes("SQLITE_CORRUPT") || err.message?.includes("malformed")) {
throw new CookieImportError(`Cookie database for ${browserName} is corrupt`, "db_corrupt");
}
throw err;
}
}
function openDbFromCopy(dbPath, browserName) {
const tmpPath = `/tmp/browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`;
try {
fs2.copyFileSync(dbPath, tmpPath);
const walPath = dbPath + "-wal";
const shmPath = dbPath + "-shm";
if (fs2.existsSync(walPath))
fs2.copyFileSync(walPath, tmpPath + "-wal");
if (fs2.existsSync(shmPath))
fs2.copyFileSync(shmPath, tmpPath + "-shm");
const db = new Database(tmpPath, { readonly: true });
const origClose = db.close.bind(db);
db.close = () => {
origClose();
try {
fs2.unlinkSync(tmpPath);
} catch {}
try {
fs2.unlinkSync(tmpPath + "-wal");
} catch {}
try {
fs2.unlinkSync(tmpPath + "-shm");
} catch {}
};
return db;
} catch {
try {
fs2.unlinkSync(tmpPath);
} catch {}
throw new CookieImportError(`Cookie database is locked (${browserName} may be running). Try closing ${browserName} first.`, "db_locked", "retry");
}
}
async function getDerivedKey(browser) {
const cached = keyCache.get(browser.keychainService);
if (cached)
return cached;
const password = await getKeychainPassword(browser.keychainService);
const derived = crypto.pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
keyCache.set(browser.keychainService, derived);
return derived;
}
async function getKeychainPassword(service) {
const proc = Bun.spawn(["security", "find-generic-password", "-s", service, "-w"], { stdout: "pipe", stderr: "pipe" });
const timeout = new Promise((_, reject) => setTimeout(() => {
proc.kill();
reject(new CookieImportError(`macOS is waiting for Keychain permission. Look for a dialog asking to allow access to "${service}".`, "keychain_timeout", "retry"));
}, 1e4));
try {
const exitCode = await Promise.race([proc.exited, timeout]);
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
const errText = stderr.trim().toLowerCase();
if (errText.includes("user canceled") || errText.includes("denied") || errText.includes("interaction not allowed")) {
throw new CookieImportError(`Keychain access denied. Click "Allow" in the macOS dialog for "${service}".`, "keychain_denied", "retry");
}
if (errText.includes("could not be found") || errText.includes("not found")) {
throw new CookieImportError(`No Keychain entry for "${service}". Is this a Chromium-based browser?`, "keychain_not_found");
}
throw new CookieImportError(`Could not read Keychain: ${stderr.trim()}`, "keychain_error", "retry");
}
return stdout.trim();
} catch (err) {
if (err instanceof CookieImportError)
throw err;
throw new CookieImportError(`Could not read Keychain: ${err.message}`, "keychain_error", "retry");
}
}
function decryptCookieValue(row, key) {
if (row.value && row.value.length > 0)
return row.value;
const ev = Buffer.from(row.encrypted_value);
if (ev.length === 0)
return "";
const prefix = ev.slice(0, 3).toString("utf-8");
if (prefix !== "v10") {
throw new Error(`Unknown encryption prefix: ${prefix}`);
}
const ciphertext = ev.slice(3);
const iv = Buffer.alloc(16, 32);
const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
if (plaintext.length <= 32)
return "";
return plaintext.slice(32).toString("utf-8");
}
function toPlaywrightCookie(row, value) {
return {
name: row.name,
value,
domain: row.host_key,
path: row.path || "/",
expires: chromiumEpochToUnix(row.expires_utc, row.has_expires),
secure: row.is_secure === 1,
httpOnly: row.is_httponly === 1,
sameSite: mapSameSite(row.samesite)
};
}
function chromiumNow() {
return BigInt(Date.now()) * 1000n + CHROMIUM_EPOCH_OFFSET;
}
function chromiumEpochToUnix(epoch, hasExpires) {
if (hasExpires === 0 || epoch === 0 || epoch === 0n)
return -1;
const epochBig = BigInt(epoch);
const unixMicro = epochBig - CHROMIUM_EPOCH_OFFSET;
return Number(unixMicro / 1000000n);
}
function mapSameSite(value) {
switch (value) {
case 0:
return "None";
case 1:
return "Lax";
case 2:
return "Strict";
default:
return "Lax";
}
}
var CookieImportError, BROWSER_REGISTRY, keyCache, CHROMIUM_EPOCH_OFFSET = 11644473600000000n;
var init_cookie_import_browser = __esm(() => {
CookieImportError = class CookieImportError extends Error {
code;
action;
constructor(message, code, action) {
super(message);
this.code = code;
this.action = action;
this.name = "CookieImportError";
}
};
BROWSER_REGISTRY = [
{ name: "Comet", dataDir: "Comet/", keychainService: "Comet Safe Storage", aliases: ["comet", "perplexity"] },
{ name: "Chrome", dataDir: "Google/Chrome/", keychainService: "Chrome Safe Storage", aliases: ["chrome", "google-chrome"] },
{ name: "Arc", dataDir: "Arc/User Data/", keychainService: "Arc Safe Storage", aliases: ["arc"] },
{ name: "Brave", dataDir: "BraveSoftware/Brave-Browser/", keychainService: "Brave Safe Storage", aliases: ["brave"] },
{ name: "Edge", dataDir: "Microsoft Edge/", keychainService: "Microsoft Edge Safe Storage", aliases: ["edge"] }
];
keyCache = new Map;
});
// browse/src/write-commands.ts
var exports_write_commands = {};
__export(exports_write_commands, {
handleWriteCommand: () => handleWriteCommand
});
import * as fs3 from "fs";
import * as path4 from "path";
async function handleWriteCommand(command, args, bm) {
const page = bm.getPage();
switch (command) {
case "goto": {
const url = args[0];
if (!url)
throw new Error("Usage: browse goto <url>");
validateNavigationUrl(url);
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
const status = response?.status() || "unknown";
return `Navigated to ${url} (${status})`;
}
case "back": {
await page.goBack({ waitUntil: "domcontentloaded", timeout: 15000 });
return `Back → ${page.url()}`;
}
case "forward": {
await page.goForward({ waitUntil: "domcontentloaded", timeout: 15000 });
return `Forward → ${page.url()}`;
}
case "reload": {
await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 });
return `Reloaded ${page.url()}`;
}
case "click": {
const selector = args[0];
if (!selector)
throw new Error("Usage: browse click <selector>");
const role = bm.getRefRole(selector);
if (role === "option") {
const resolved2 = await bm.resolveRef(selector);
if ("locator" in resolved2) {
const optionInfo = await resolved2.locator.evaluate((el) => {
if (el.tagName !== "OPTION")
return null;
const option = el;
const select = option.closest("select");
if (!select)
return null;
return { value: option.value, text: option.text };
});
if (optionInfo) {
await resolved2.locator.locator("xpath=ancestor::select").selectOption(optionInfo.value, { timeout: 5000 });
return `Selected "${optionInfo.text}" (auto-routed from click on <option>) → now at ${page.url()}`;
}
}
}
const resolved = await bm.resolveRef(selector);
try {
if ("locator" in resolved) {
await resolved.locator.click({ timeout: 5000 });
} else {
await page.click(resolved.selector, { timeout: 5000 });
}
} catch (err) {
const isOption = "locator" in resolved ? await resolved.locator.evaluate((el) => el.tagName === "OPTION").catch(() => false) : await page.evaluate((sel) => document.querySelector(sel)?.tagName === "OPTION", resolved.selector).catch(() => false);
if (isOption) {
throw new Error(`Cannot click <option> elements. Use 'browse select <parent-select> <value>' instead of 'click' for dropdown options.`);
}
throw err;
}
await page.waitForLoadState("domcontentloaded").catch(() => {});
return `Clicked ${selector} → now at ${page.url()}`;
}
case "fill": {
const [selector, ...valueParts] = args;
const value = valueParts.join(" ");
if (!selector || !value)
throw new Error("Usage: browse fill <selector> <value>");
const resolved = await bm.resolveRef(selector);
if ("locator" in resolved) {
await resolved.locator.fill(value, { timeout: 5000 });
} else {
await page.fill(resolved.selector, value, { timeout: 5000 });
}
return `Filled ${selector}`;
}
case "select": {
const [selector, ...valueParts] = args;
const value = valueParts.join(" ");
if (!selector || !value)
throw new Error("Usage: browse select <selector> <value>");
const resolved = await bm.resolveRef(selector);
if ("locator" in resolved) {
await resolved.locator.selectOption(value, { timeout: 5000 });
} else {
await page.selectOption(resolved.selector, value, { timeout: 5000 });
}
return `Selected "${value}" in ${selector}`;
}
case "hover": {
const selector = args[0];
if (!selector)
throw new Error("Usage: browse hover <selector>");
const resolved = await bm.resolveRef(selector);
if ("locator" in resolved) {
await resolved.locator.hover({ timeout: 5000 });
} else {
await page.hover(resolved.selector, { timeout: 5000 });
}
return `Hovered ${selector}`;
}
case "type": {
const text = args.join(" ");
if (!text)
throw new Error("Usage: browse type <text>");
await page.keyboard.type(text);
return `Typed ${text.length} characters`;
}
case "press": {
const key = args[0];
if (!key)
throw new Error("Usage: browse press <key> (e.g., Enter, Tab, Escape)");
await page.keyboard.press(key);
return `Pressed ${key}`;
}
case "scroll": {
const selector = args[0];
if (selector) {
const resolved = await bm.resolveRef(selector);
if ("locator" in resolved) {
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
} else {
await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
}
return `Scrolled ${selector} into view`;
}
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
return "Scrolled to bottom";
}
case "wait": {
const selector = args[0];
if (!selector)
throw new Error("Usage: browse wait <selector|--networkidle|--load|--domcontentloaded>");
if (selector === "--networkidle") {
const timeout2 = args[1] ? parseInt(args[1], 10) : 15000;
await page.waitForLoadState("networkidle", { timeout: timeout2 });
return "Network idle";
}
if (selector === "--load") {
await page.waitForLoadState("load");
return "Page loaded";
}
if (selector === "--domcontentloaded") {
await page.waitForLoadState("domcontentloaded");
return "DOM content loaded";
}
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
const resolved = await bm.resolveRef(selector);
if ("locator" in resolved) {
await resolved.locator.waitFor({ state: "visible", timeout });
} else {
await page.waitForSelector(resolved.selector, { timeout });
}
return `Element ${selector} appeared`;
}
case "viewport": {
const size = args[0];
if (!size || !size.includes("x"))
throw new Error("Usage: browse viewport <WxH> (e.g., 375x812)");
const [w, h] = size.split("x").map(Number);
await bm.setViewport(w, h);
return `Viewport set to ${w}x${h}`;
}
case "cookie": {
const cookieStr = args[0];
if (!cookieStr || !cookieStr.includes("="))
throw new Error("Usage: browse cookie <name>=<value>");
const eq = cookieStr.indexOf("=");
const name = cookieStr.slice(0, eq);
const value = cookieStr.slice(eq + 1);
const url = new URL(page.url());
await page.context().addCookies([{
name,
value,
domain: url.hostname,
path: "/"
}]);
return `Cookie set: ${name}=****`;
}
case "header": {
const headerStr = args[0];
if (!headerStr || !headerStr.includes(":"))
throw new Error("Usage: browse header <name>:<value>");
const sep2 = headerStr.indexOf(":");
const name = headerStr.slice(0, sep2).trim();
const value = headerStr.slice(sep2 + 1).trim();
await bm.setExtraHeader(name, value);
const sensitiveHeaders = ["authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token"];
const redactedValue = sensitiveHeaders.includes(name.toLowerCase()) ? "****" : value;
return `Header set: ${name}: ${redactedValue}`;
}
case "useragent": {
const ua = args.join(" ");
if (!ua)
throw new Error("Usage: browse useragent <string>");
bm.setUserAgent(ua);
const error = await bm.recreateContext();
if (error) {
return `User agent set to "${ua}" but: ${error}`;
}
return `User agent set: ${ua}`;
}
case "upload": {
const [selector, ...filePaths] = args;
if (!selector || filePaths.length === 0)
throw new Error("Usage: browse upload <selector> <file1> [file2...]");
for (const fp of filePaths) {
if (!fs3.existsSync(fp))
throw new Error(`File not found: ${fp}`);
}
const resolved = await bm.resolveRef(selector);
if ("locator" in resolved) {
await resolved.locator.setInputFiles(filePaths);
} else {
await page.locator(resolved.selector).setInputFiles(filePaths);
}
const fileInfo = filePaths.map((fp) => {
const stat = fs3.statSync(fp);
return `${path4.basename(fp)} (${stat.size}B)`;
}).join(", ");
return `Uploaded: ${fileInfo}`;
}
case "dialog-accept": {
const text = args.length > 0 ? args.join(" ") : null;
bm.setDialogAutoAccept(true);
bm.setDialogPromptText(text);
return text ? `Dialogs will be accepted with text: "${text}"` : "Dialogs will be accepted";
}
case "dialog-dismiss": {
bm.setDialogAutoAccept(false);
bm.setDialogPromptText(null);
return "Dialogs will be dismissed";
}
case "cookie-import": {
const filePath = args[0];
if (!filePath)
throw new Error("Usage: browse cookie-import <json-file>");
if (path4.isAbsolute(filePath)) {
const safeDirs = [TEMP_DIR, process.cwd()];
const resolved = path4.resolve(filePath);
if (!safeDirs.some((dir) => isPathWithin(resolved, dir))) {
throw new Error(`Path must be within: ${safeDirs.join(", ")}`);
}
}
if (path4.normalize(filePath).includes("..")) {
throw new Error("Path traversal sequences (..) are not allowed");
}
if (!fs3.existsSync(filePath))
throw new Error(`File not found: ${filePath}`);
const raw = fs3.readFileSync(filePath, "utf-8");
let cookies;
try {
cookies = JSON.parse(raw);
} catch {
throw new Error(`Invalid JSON in ${filePath}`);
}
if (!Array.isArray(cookies))
throw new Error("Cookie file must contain a JSON array");
const pageUrl = new URL(page.url());
const defaultDomain = pageUrl.hostname;
for (const c of cookies) {
if (!c.name || c.value === undefined)
throw new Error('Each cookie must have "name" and "value" fields');
if (!c.domain)
c.domain = defaultDomain;
if (!c.path)
c.path = "/";
}
await page.context().addCookies(cookies);
return `Loaded ${cookies.length} cookies from ${filePath}`;
}
case "cookie-import-browser": {
const browserArg = args[0];
const domainIdx = args.indexOf("--domain");
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
const domain = args[domainIdx + 1];
const browser = browserArg || "comet";
const result = await importCookies(browser, [domain]);
if (result.cookies.length > 0) {
await page.context().addCookies(result.cookies);
}
const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`];
if (result.failed > 0)
msg.push(`(${result.failed} failed to decrypt)`);
return msg.join(" ");
}
const port = bm.serverPort;
if (!port)
throw new Error("Server port not available");
const browsers = findInstalledBrowsers();
if (browsers.length === 0) {
throw new Error("No Chromium browsers found. Supported: Comet, Chrome, Arc, Brave, Edge");
}
const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`;
try {
Bun.spawn(["open", pickerUrl], { stdout: "ignore", stderr: "ignore" });
} catch {}
return `Cookie picker opened at ${pickerUrl}
Detected browsers: ${browsers.map((b) => b.name).join(", ")}
Select domains to import, then close the picker when done.`;
}
default:
throw new Error(`Unknown write command: ${command}`);
}
}
var init_write_commands = __esm(() => {
init_cookie_import_browser();
init_url_validation();
init_platform();
});
// browse/src/browser-manager.ts
init_buffers();
init_url_validation();
import { chromium } from "playwright";
class BrowserManager {
browser = null;
context = null;
pages = new Map;
activeTabId = 0;
nextTabId = 1;
extraHeaders = {};
customUserAgent = null;
serverPort = 0;
refMap = new Map;
lastSnapshot = null;
dialogAutoAccept = true;
dialogPromptText = null;
isHeaded = false;
consecutiveFailures = 0;
async launch() {
this.browser = await chromium.launch({ headless: true });
this.browser.on("disconnected", () => {
console.error("[browse] FATAL: Chromium process crashed or was killed. Server exiting.");
console.error("[browse] Console/network logs flushed to .gstack/browse-*.log");
process.exit(1);
});
const contextOptions = {
viewport: { width: 1280, height: 720 }
};
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
this.context = await this.browser.newContext(contextOptions);
if (Object.keys(this.extraHeaders).length > 0) {
await this.context.setExtraHTTPHeaders(this.extraHeaders);
}
await this.newTab();
}
async close() {
if (this.browser) {
this.browser.removeAllListeners("disconnected");
await Promise.race([
this.browser.close(),
new Promise((resolve) => setTimeout(resolve, 5000))
]).catch(() => {});
this.browser = null;
}
}
async isHealthy() {
if (!this.browser || !this.browser.isConnected())
return false;
try {
const page = this.pages.get(this.activeTabId);
if (!page)
return true;
await Promise.race([
page.evaluate("1"),
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 2000))
]);
return true;
} catch {
return false;
}
}
async newTab(url) {
if (!this.context)
throw new Error("Browser not launched");
if (url) {
validateNavigationUrl(url);
}
const page = await this.context.newPage();
const id = this.nextTabId++;
this.pages.set(id, page);
this.activeTabId = id;
this.wirePageEvents(page);
if (url) {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
}
return id;
}
async closeTab(id) {
const tabId = id ?? this.activeTabId;
const page = this.pages.get(tabId);
if (!page)
throw new Error(`Tab ${tabId} not found`);
await page.close();
this.pages.delete(tabId);
if (tabId === this.activeTabId) {
const remaining = [...this.pages.keys()];
if (remaining.length > 0) {
this.activeTabId = remaining[remaining.length - 1];
} else {
await this.newTab();
}
}
}
switchTab(id) {
if (!this.pages.has(id))
throw new Error(`Tab ${id} not found`);
this.activeTabId = id;
}
getTabCount() {
return this.pages.size;
}
async getTabListWithTitles() {
const tabs = [];
for (const [id, page] of this.pages) {
tabs.push({
id,
url: page.url(),
title: await page.title().catch(() => ""),
active: id === this.activeTabId
});
}
return tabs;
}
getPage() {
const page = this.pages.get(this.activeTabId);
if (!page)
throw new Error('No active page. Use "browse goto <url>" first.');
return page;
}
getCurrentUrl() {
try {
return this.getPage().url();
} catch {
return "about:blank";
}
}
setRefMap(refs) {
this.refMap = refs;
}
clearRefs() {
this.refMap.clear();
}
async resolveRef(selector) {
if (selector.startsWith("@e") || selector.startsWith("@c")) {
const ref = selector.slice(1);
const entry = this.refMap.get(ref);
if (!entry) {
throw new Error(`Ref ${selector} not found. Run 'snapshot' to get fresh refs.`);
}
const count = await entry.locator.count();
if (count === 0) {
throw new Error(`Ref ${selector} (${entry.role} "${entry.name}") is stale — element no longer exists. ` + `Run 'snapshot' for fresh refs.`);
}
return { locator: entry.locator };
}
return { selector };
}
getRefRole(selector) {
if (selector.startsWith("@e") || selector.startsWith("@c")) {
const entry = this.refMap.get(selector.slice(1));
return entry?.role ?? null;
}
return null;
}
getRefCount() {
return this.refMap.size;
}
setLastSnapshot(text) {
this.lastSnapshot = text;
}
getLastSnapshot() {
return this.lastSnapshot;
}
setDialogAutoAccept(accept) {
this.dialogAutoAccept = accept;
}
getDialogAutoAccept() {
return this.dialogAutoAccept;
}
setDialogPromptText(text) {
this.dialogPromptText = text;
}
getDialogPromptText() {
return this.dialogPromptText;
}
async setViewport(width, height) {
await this.getPage().setViewportSize({ width, height });
}
async setExtraHeader(name, value) {
this.extraHeaders[name] = value;
if (this.context) {
await this.context.setExtraHTTPHeaders(this.extraHeaders);
}
}
setUserAgent(ua) {
this.customUserAgent = ua;
}
getUserAgent() {
return this.customUserAgent;
}
async saveState() {
if (!this.context)
throw new Error("Browser not launched");
const cookies = await this.context.cookies();
const pages = [];
for (const [id, page] of this.pages) {
const url = page.url();
let storage = null;
try {
storage = await page.evaluate(() => ({
localStorage: { ...localStorage },
sessionStorage: { ...sessionStorage }
}));
} catch {}
pages.push({
url: url === "about:blank" ? "" : url,
isActive: id === this.activeTabId,
storage
});
}
return { cookies, pages };
}
async restoreState(state) {
if (!this.context)
throw new Error("Browser not launched");
if (state.cookies.length > 0) {
await this.context.addCookies(state.cookies);
}
let activeId = null;
for (const saved of state.pages) {
const page = await this.context.newPage();
const id = this.nextTabId++;
this.pages.set(id, page);
this.wirePageEvents(page);
if (saved.url) {
await page.goto(saved.url, { waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => {});
}
if (saved.storage) {
try {
await page.evaluate((s) => {
if (s.localStorage) {
for (const [k, v] of Object.entries(s.localStorage)) {
localStorage.setItem(k, v);
}
}
if (s.sessionStorage) {
for (const [k, v] of Object.entries(s.sessionStorage)) {
sessionStorage.setItem(k, v);
}
}
}, saved.storage);
} catch {}
}
if (saved.isActive)
activeId = id;
}
if (this.pages.size === 0) {
await this.newTab();
} else {
this.activeTabId = activeId ?? [...this.pages.keys()][0];
}
this.clearRefs();
}
async recreateContext() {
if (!this.browser || !this.context) {
throw new Error("Browser not launched");
}
try {
const state = await this.saveState();
for (const page of this.pages.values()) {
await page.close().catch(() => {});
}
this.pages.clear();
await this.context.close().catch(() => {});
const contextOptions = {
viewport: { width: 1280, height: 720 }
};
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
this.context = await this.browser.newContext(contextOptions);
if (Object.keys(this.extraHeaders).length > 0) {
await this.context.setExtraHTTPHeaders(this.extraHeaders);
}
await this.restoreState(state);
return null;
} catch (err) {
try {
this.pages.clear();
if (this.context)
await this.context.close().catch(() => {});
const contextOptions = {
viewport: { width: 1280, height: 720 }
};
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
this.context = await this.browser.newContext(contextOptions);
await this.newTab();
this.clearRefs();
} catch {}
return `Context recreation failed: ${err instanceof Error ? err.message : String(err)}. Browser reset to blank tab.`;
}
}
async handoff(message) {
if (this.isHeaded) {
return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`;
}
if (!this.browser || !this.context) {
throw new Error("Browser not launched");
}
const state = await this.saveState();
const currentUrl = this.getCurrentUrl();
let newBrowser;
try {
newBrowser = await chromium.launch({ headless: false, timeout: 15000 });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return `ERROR: Cannot open headed browser — ${msg}. Headless browser still running.`;
}
try {
const contextOptions = {
viewport: { width: 1280, height: 720 }
};
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
const newContext = await newBrowser.newContext(contextOptions);
if (Object.keys(this.extraHeaders).length > 0) {
await newContext.setExtraHTTPHeaders(this.extraHeaders);
}
const oldBrowser = this.browser;
const oldContext = this.context;
this.browser = newBrowser;
this.context = newContext;
this.pages.clear();
this.browser.on("disconnected", () => {
console.error("[browse] FATAL: Chromium process crashed or was killed. Server exiting.");
console.error("[browse] Console/network logs flushed to .gstack/browse-*.log");
process.exit(1);
});
await this.restoreState(state);
this.isHeaded = true;
oldBrowser.removeAllListeners("disconnected");
oldBrowser.close().catch(() => {});
return [
`HANDOFF: Browser opened at ${currentUrl}`,
`MESSAGE: ${message}`,
`STATUS: Waiting for user. Run 'resume' when done.`
].join(`
`);
} catch (err) {
await newBrowser.close().catch(() => {});
const msg = err instanceof Error ? err.message : String(err);
return `ERROR: Handoff failed during state restore — ${msg}. Headless browser still running.`;
}
}
resume() {
this.clearRefs();
this.resetFailures();
}
getIsHeaded() {
return this.isHeaded;
}
incrementFailures() {
this.consecutiveFailures++;
}
resetFailures() {
this.consecutiveFailures = 0;
}
getFailureHint() {
if (this.consecutiveFailures >= 3 && !this.isHeaded) {
return `HINT: ${this.consecutiveFailures} consecutive failures. Consider using 'handoff' to let the user help.`;
}
return null;
}
wirePageEvents(page) {
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
this.clearRefs();
}
});
page.on("dialog", async (dialog) => {
const entry = {
timestamp: Date.now(),
type: dialog.type(),
message: dialog.message(),
defaultValue: dialog.defaultValue() || undefined,
action: this.dialogAutoAccept ? "accepted" : "dismissed",
response: this.dialogAutoAccept ? this.dialogPromptText ?? undefined : undefined
};
addDialogEntry(entry);
try {
if (this.dialogAutoAccept) {
await dialog.accept(this.dialogPromptText ?? undefined);
} else {
await dialog.dismiss();
}
} catch {}
});
page.on("console", (msg) => {
addConsoleEntry({
timestamp: Date.now(),
level: msg.type(),
text: msg.text()
});
});
page.on("request", (req) => {
addNetworkEntry({
timestamp: Date.now(),
method: req.method(),
url: req.url()
});
});
page.on("response", (res) => {
const url = res.url();
const status = res.status();
for (let i = networkBuffer.length - 1;i >= 0; i--) {
const entry = networkBuffer.get(i);
if (entry && entry.url === url && !entry.status) {
networkBuffer.set(i, { ...entry, status, duration: Date.now() - entry.timestamp });
break;
}
}
});
page.on("requestfinished", async (req) => {
try {
const res = await req.response();
if (res) {
const url = req.url();
const body = await res.body().catch(() => null);
const size = body ? body.length : 0;
for (let i = networkBuffer.length - 1;i >= 0; i--) {
const entry = networkBuffer.get(i);
if (entry && entry.url === url && !entry.size) {
networkBuffer.set(i, { ...entry, size });
break;
}
}
}
} catch {}
});
}
}
// browse/src/server.ts
init_read_commands();
init_write_commands();
// browse/src/snapshot.ts
init_platform();
import * as Diff from "diff";
var INTERACTIVE_ROLES = new Set([
"button",
"link",
"textbox",
"checkbox",
"radio",
"combobox",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"searchbox",
"slider",
"spinbutton",
"switch",
"tab",
"treeitem"
]);
var SNAPSHOT_FLAGS = [
{ short: "-i", long: "--interactive", description: "Interactive elements only (buttons, links, inputs) with @e refs", optionKey: "interactive" },
{ short: "-c", long: "--compact", description: "Compact (no empty structural nodes)", optionKey: "compact" },
{ short: "-d", long: "--depth", description: "Limit tree depth (0 = root only, default: unlimited)", takesValue: true, valueHint: "<N>", optionKey: "depth" },
{ short: "-s", long: "--selector", description: "Scope to CSS selector", takesValue: true, valueHint: "<sel>", optionKey: "selector" },
{ short: "-D", long: "--diff", description: "Unified diff against previous snapshot (first call stores baseline)", optionKey: "diff" },
{ short: "-a", long: "--annotate", description: "Annotated screenshot with red overlay boxes and ref labels", optionKey: "annotate" },
{ short: "-o", long: "--output", description: "Output path for annotated screenshot (default: <temp>/browse-annotated.png)", takesValue: true, valueHint: "<path>", optionKey: "outputPath" },
{ short: "-C", long: "--cursor-interactive", description: "Cursor-interactive elements (@c refs — divs with pointer, onclick)", optionKey: "cursorInteractive" }
];
function parseSnapshotArgs(args) {
const opts = {};
for (let i = 0;i < args.length; i++) {
const flag = SNAPSHOT_FLAGS.find((f) => f.short === args[i] || f.long === args[i]);
if (!flag)
throw new Error(`Unknown snapshot flag: ${args[i]}`);
if (flag.takesValue) {
const value = args[++i];
if (!value)
throw new Error(`Usage: snapshot ${flag.short} <value>`);
if (flag.optionKey === "depth") {
opts[flag.optionKey] = parseInt(value, 10);
if (isNaN(opts.depth))
throw new Error("Usage: snapshot -d <number>");
} else {
opts[flag.optionKey] = value;
}
} else {
opts[flag.optionKey] = true;
}
}
return opts;
}
function parseLine(line) {
const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/);
if (!match) {
return null;
}
return {
indent: match[1].length,
role: match[2],
name: match[3] ?? null,
props: match[4] || "",
children: match[5]?.trim() || "",
rawLine: line
};
}
async function handleSnapshot(args, bm) {
const opts = parseSnapshotArgs(args);
const page = bm.getPage();
let rootLocator;
if (opts.selector) {
rootLocator = page.locator(opts.selector);
const count = await rootLocator.count();
if (count === 0)
throw new Error(`Selector not found: ${opts.selector}`);
} else {
rootLocator = page.locator("body");
}
const ariaText = await rootLocator.ariaSnapshot();
if (!ariaText || ariaText.trim().length === 0) {
bm.setRefMap(new Map);
return "(no accessible elements found)";
}
const lines = ariaText.split(`
`);
const refMap = new Map;
const output = [];
let refCounter = 1;
const roleNameCounts = new Map;
const roleNameSeen = new Map;
for (const line of lines) {
const node = parseLine(line);
if (!node)
continue;
const key = `${node.role}:${node.name || ""}`;
roleNameCounts.set(key, (roleNameCounts.get(key) || 0) + 1);
}
for (const line of lines) {
const node = parseLine(line);
if (!node)
continue;
const depth = Math.floor(node.indent / 2);
const isInteractive = INTERACTIVE_ROLES.has(node.role);
if (opts.depth !== undefined && depth > opts.depth)
continue;
if (opts.interactive && !isInteractive) {
const key2 = `${node.role}:${node.name || ""}`;
roleNameSeen.set(key2, (roleNameSeen.get(key2) || 0) + 1);
continue;
}
if (opts.compact && !isInteractive && !node.name && !node.children)
continue;
const ref = `e${refCounter++}`;
const indent = " ".repeat(depth);
const key = `${node.role}:${node.name || ""}`;
const seenIndex = roleNameSeen.get(key) || 0;
roleNameSeen.set(key, seenIndex + 1);
const totalCount = roleNameCounts.get(key) || 1;
let locator;
if (opts.selector) {
locator = page.locator(opts.selector).getByRole(node.role, {
name: node.name || undefined
});
} else {
locator = page.getByRole(node.role, {
name: node.name || undefined
});
}
if (totalCount > 1) {
locator = locator.nth(seenIndex);
}
refMap.set(ref, { locator, role: node.role, name: node.name || "" });
let outputLine = `${indent}@${ref} [${node.role}]`;
if (node.name)
outputLine += ` "${node.name}"`;
if (node.props)
outputLine += ` ${node.props}`;
if (node.children)
outputLine += `: ${node.children}`;
output.push(outputLine);
}
if (opts.cursorInteractive) {
try {
const cursorElements = await page.evaluate(() => {
const STANDARD_INTERACTIVE = new Set([
"A",
"BUTTON",
"INPUT",
"SELECT",
"TEXTAREA",
"SUMMARY",
"DETAILS"
]);
const results = [];
const allElements = document.querySelectorAll("*");
for (const el of allElements) {
if (STANDARD_INTERACTIVE.has(el.tagName))
continue;
if (!el.offsetParent && el.tagName !== "BODY")
continue;
const style = getComputedStyle(el);
const hasCursorPointer = style.cursor === "pointer";
const hasOnclick = el.hasAttribute("onclick");
const hasTabindex = el.hasAttribute("tabindex") && parseInt(el.getAttribute("tabindex"), 10) >= 0;
const hasRole = el.hasAttribute("role");
if (!hasCursorPointer && !hasOnclick && !hasTabindex)
continue;
if (hasRole)
continue;
const parts = [];
let current = el;
while (current && current !== document.documentElement) {
const parent = current.parentElement;
if (!parent)
break;
const siblings = [...parent.children];
const index = siblings.indexOf(current) + 1;
parts.unshift(`${current.tagName.toLowerCase()}:nth-child(${index})`);
current = parent;
}
const selector = parts.join(" > ");
const text = el.innerText?.trim().slice(0, 80) || el.tagName.toLowerCase();
const reasons = [];
if (hasCursorPointer)
reasons.push("cursor:pointer");
if (hasOnclick)
reasons.push("onclick");
if (hasTabindex)
reasons.push(`tabindex=${el.getAttribute("tabindex")}`);
results.push({ selector, text, reason: reasons.join(", ") });
}
return results;
});
if (cursorElements.length > 0) {
output.push("");
output.push("── cursor-interactive (not in ARIA tree) ──");
let cRefCounter = 1;
for (const elem of cursorElements) {
const ref = `c${cRefCounter++}`;
const locator = page.locator(elem.selector);
refMap.set(ref, { locator, role: "cursor-interactive", name: elem.text });
output.push(`@${ref} [${elem.reason}] "${elem.text}"`);
}
}
} catch {
output.push("");
output.push("(cursor scan failed — CSP restriction)");
}
}
bm.setRefMap(refMap);
if (output.length === 0) {
return "(no interactive elements found)";
}
const snapshotText = output.join(`
`);
if (opts.annotate) {
const screenshotPath = opts.outputPath || `${TEMP_DIR}/browse-annotated.png`;
const resolvedPath = __require("path").resolve(screenshotPath);
const safeDirs = [TEMP_DIR, process.cwd()];
if (!safeDirs.some((dir) => isPathWithin(resolvedPath, dir))) {
throw new Error(`Path must be within: ${safeDirs.join(", ")}`);
}
try {
const boxes = [];
for (const [ref, entry] of refMap) {
try {
const box = await entry.locator.boundingBox({ timeout: 1000 });
if (box) {
boxes.push({ ref: `@${ref}`, box });
}
} catch {}
}
await page.evaluate((boxes2) => {
for (const { ref, box } of boxes2) {
const overlay = document.createElement("div");
overlay.className = "__browse_annotation__";
overlay.style.cssText = `
position: absolute; top: ${box.y}px; left: ${box.x}px;
width: ${box.width}px; height: ${box.height}px;
border: 2px solid red; background: rgba(255,0,0,0.1);
pointer-events: none; z-index: 99999;
font-size: 10px; color: red; font-weight: bold;
`;
const label = document.createElement("span");
label.textContent = ref;
label.style.cssText = "position: absolute; top: -14px; left: 0; background: red; color: white; padding: 0 3px; font-size: 10px;";
overlay.appendChild(label);
document.body.appendChild(overlay);
}
}, boxes);
await page.screenshot({ path: screenshotPath, fullPage: true });
await page.evaluate(() => {
document.querySelectorAll(".__browse_annotation__").forEach((el) => el.remove());
});
output.push("");
output.push(`[annotated screenshot: ${screenshotPath}]`);
} catch {
try {
await page.evaluate(() => {
document.querySelectorAll(".__browse_annotation__").forEach((el) => el.remove());
});
} catch {}
}
}
if (opts.diff) {
const lastSnapshot = bm.getLastSnapshot();
if (!lastSnapshot) {
bm.setLastSnapshot(snapshotText);
return snapshotText + `
(no previous snapshot to diff against — this snapshot stored as baseline)`;
}
const changes = Diff.diffLines(lastSnapshot, snapshotText);
const diffOutput = ["--- previous snapshot", "+++ current snapshot", ""];
for (const part of changes) {
const prefix = part.added ? "+" : part.removed ? "-" : " ";
const diffLines2 = part.value.split(`
`).filter((l) => l.length > 0);
for (const line of diffLines2) {
diffOutput.push(`${prefix} ${line}`);
}
}
bm.setLastSnapshot(snapshotText);
return diffOutput.join(`
`);
}
bm.setLastSnapshot(snapshotText);
return output.join(`
`);
}
// browse/src/meta-commands.ts
init_read_commands();
// browse/src/commands.ts
var READ_COMMANDS = new Set([
"text",
"html",
"links",
"forms",
"accessibility",
"js",
"eval",
"css",
"attrs",
"console",
"network",
"cookies",
"storage",
"perf",
"dialog",
"is"
]);
var WRITE_COMMANDS = new Set([
"goto",
"back",
"forward",
"reload",
"click",
"fill",
"select",
"hover",
"type",
"press",
"scroll",
"wait",
"viewport",
"cookie",
"cookie-import",
"cookie-import-browser",
"header",
"useragent",
"upload",
"dialog-accept",
"dialog-dismiss"
]);
var META_COMMANDS = new Set([
"tabs",
"tab",
"newtab",
"closetab",
"status",
"stop",
"restart",
"screenshot",
"pdf",
"responsive",
"chain",
"diff",
"url",
"snapshot",
"handoff",
"resume"
]);
var ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
var COMMAND_DESCRIPTIONS = {
goto: { category: "Navigation", description: "Navigate to URL", usage: "goto <url>" },
back: { category: "Navigation", description: "History back" },
forward: { category: "Navigation", description: "History forward" },
reload: { category: "Navigation", description: "Reload page" },
url: { category: "Navigation", description: "Print current URL" },
text: { category: "Reading", description: "Cleaned page text" },
html: { category: "Reading", description: "innerHTML of selector (throws if not found), or full page HTML if no selector given", usage: "html [selector]" },
links: { category: "Reading", description: 'All links as "text → href"' },
forms: { category: "Reading", description: "Form fields as JSON" },
accessibility: { category: "Reading", description: "Full ARIA tree" },
js: { category: "Inspection", description: "Run JavaScript expression and return result as string", usage: "js <expr>" },
eval: { category: "Inspection", description: "Run JavaScript from file and return result as string (path must be under /tmp or cwd)", usage: "eval <file>" },
css: { category: "Inspection", description: "Computed CSS value", usage: "css <sel> <prop>" },
attrs: { category: "Inspection", description: "Element attributes as JSON", usage: "attrs <sel|@ref>" },
is: { category: "Inspection", description: "State check (visible/hidden/enabled/disabled/checked/editable/focused)", usage: "is <prop> <sel>" },
console: { category: "Inspection", description: "Console messages (--errors filters to error/warning)", usage: "console [--clear|--errors]" },
network: { category: "Inspection", description: "Network requests", usage: "network [--clear]" },
dialog: { category: "Inspection", description: "Dialog messages", usage: "dialog [--clear]" },
cookies: { category: "Inspection", description: "All cookies as JSON" },
storage: { category: "Inspection", description: "Read all localStorage + sessionStorage as JSON, or set <key> <value> to write localStorage", usage: "storage [set k v]" },
perf: { category: "Inspection", description: "Page load timings" },
click: { category: "Interaction", description: "Click element", usage: "click <sel>" },
fill: { category: "Interaction", description: "Fill input", usage: "fill <sel> <val>" },
select: { category: "Interaction", description: "Select dropdown option by value, label, or visible text", usage: "select <sel> <val>" },
hover: { category: "Interaction", description: "Hover element", usage: "hover <sel>" },
type: { category: "Interaction", description: "Type into focused element", usage: "type <text>" },
press: { category: "Interaction", description: "Press key — Enter, Tab, Escape, ArrowUp/Down/Left/Right, Backspace, Delete, Home, End, PageUp, PageDown, or modifiers like Shift+Enter", usage: "press <key>" },
scroll: { category: "Interaction", description: "Scroll element into view, or scroll to page bottom if no selector", usage: "scroll [sel]" },
wait: { category: "Interaction", description: "Wait for element, network idle, or page load (timeout: 15s)", usage: "wait <sel|--networkidle|--load>" },
upload: { category: "Interaction", description: "Upload file(s)", usage: "upload <sel> <file> [file2...]" },
viewport: { category: "Interaction", description: "Set viewport size", usage: "viewport <WxH>" },
cookie: { category: "Interaction", description: "Set cookie on current page domain", usage: "cookie <name>=<value>" },
"cookie-import": { category: "Interaction", description: "Import cookies from JSON file", usage: "cookie-import <json>" },
"cookie-import-browser": { category: "Interaction", description: "Import cookies from Comet, Chrome, Arc, Brave, or Edge (opens picker, or use --domain for direct import)", usage: "cookie-import-browser [browser] [--domain d]" },
header: { category: "Interaction", description: "Set custom request header (colon-separated, sensitive values auto-redacted)", usage: "header <name>:<value>" },
useragent: { category: "Interaction", description: "Set user agent", usage: "useragent <string>" },
"dialog-accept": { category: "Interaction", description: "Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response", usage: "dialog-accept [text]" },
"dialog-dismiss": { category: "Interaction", description: "Auto-dismiss next dialog" },
screenshot: { category: "Visual", description: "Save screenshot (supports element crop via CSS/@ref, --clip region, --viewport)", usage: "screenshot [--viewport] [--clip x,y,w,h] [selector|@ref] [path]" },
pdf: { category: "Visual", description: "Save as PDF", usage: "pdf [path]" },
responsive: { category: "Visual", description: "Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc.", usage: "responsive [prefix]" },
diff: { category: "Visual", description: "Text diff between pages", usage: "diff <url1> <url2>" },
tabs: { category: "Tabs", description: "List open tabs" },
tab: { category: "Tabs", description: "Switch to tab", usage: "tab <id>" },
newtab: { category: "Tabs", description: "Open new tab", usage: "newtab [url]" },
closetab: { category: "Tabs", description: "Close tab", usage: "closetab [id]" },
status: { category: "Server", description: "Health check" },
stop: { category: "Server", description: "Shutdown server" },
restart: { category: "Server", description: "Restart server" },
snapshot: { category: "Snapshot", description: "Accessibility tree with @e refs for element selection. Flags: -i interactive only, -c compact, -d N depth limit, -s sel scope, -D diff vs previous, -a annotated screenshot, -o path output, -C cursor-interactive @c refs", usage: "snapshot [flags]" },
chain: { category: "Meta", description: 'Run commands from JSON stdin. Format: [["cmd","arg1",...],...]' },
handoff: { category: "Server", description: "Open visible Chrome at current page for user takeover", usage: "handoff [message]" },
resume: { category: "Server", description: "Re-snapshot after user takeover, return control to AI", usage: "resume" }
};
var allCmds = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
var descKeys = new Set(Object.keys(COMMAND_DESCRIPTIONS));
for (const cmd of allCmds) {
if (!descKeys.has(cmd))
throw new Error(`COMMAND_DESCRIPTIONS missing entry for: ${cmd}`);
}
for (const key of descKeys) {
if (!allCmds.has(key))
throw new Error(`COMMAND_DESCRIPTIONS has unknown command: ${key}`);
}
// browse/src/meta-commands.ts
init_url_validation();
init_platform();
import * as Diff2 from "diff";
import * as path5 from "path";
var SAFE_DIRECTORIES2 = [TEMP_DIR, process.cwd()];
function validateOutputPath(filePath) {
const resolved = path5.resolve(filePath);
const isSafe = SAFE_DIRECTORIES2.some((dir) => isPathWithin(resolved, dir));
if (!isSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES2.join(", ")}`);
}
}
async function handleMetaCommand(command, args, bm, shutdown) {
switch (command) {
case "tabs": {
const tabs = await bm.getTabListWithTitles();
return tabs.map((t) => `${t.active ? "→ " : " "}[${t.id}] ${t.title || "(untitled)"}${t.url}`).join(`
`);
}
case "tab": {
const id = parseInt(args[0], 10);
if (isNaN(id))
throw new Error("Usage: browse tab <id>");
bm.switchTab(id);
return `Switched to tab ${id}`;
}
case "newtab": {
const url = args[0];
const id = await bm.newTab(url);
return `Opened tab ${id}${url ? `${url}` : ""}`;
}
case "closetab": {
const id = args[0] ? parseInt(args[0], 10) : undefined;
await bm.closeTab(id);
return `Closed tab${id ? ` ${id}` : ""}`;
}
case "status": {
const page = bm.getPage();
const tabs = bm.getTabCount();
return [
`Status: healthy`,
`URL: ${page.url()}`,
`Tabs: ${tabs}`,
`PID: ${process.pid}`
].join(`
`);
}
case "url": {
return bm.getCurrentUrl();
}
case "stop": {
await shutdown();
return "Server stopped";
}
case "restart": {
console.log("[browse] Restart requested. Exiting for CLI to restart.");
await shutdown();
return "Restarting...";
}
case "screenshot": {
const page = bm.getPage();
let outputPath = `${TEMP_DIR}/browse-screenshot.png`;
let clipRect;
let targetSelector;
let viewportOnly = false;
const remaining = [];
for (let i = 0;i < args.length; i++) {
if (args[i] === "--viewport") {
viewportOnly = true;
} else if (args[i] === "--clip") {
const coords = args[++i];
if (!coords)
throw new Error("Usage: screenshot --clip x,y,w,h [path]");
const parts = coords.split(",").map(Number);
if (parts.length !== 4 || parts.some(isNaN))
throw new Error("Usage: screenshot --clip x,y,width,height — all must be numbers");
clipRect = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
} else if (args[i].startsWith("--")) {
throw new Error(`Unknown screenshot flag: ${args[i]}`);
} else {
remaining.push(args[i]);
}
}
for (const arg of remaining) {
if (arg.startsWith("@e") || arg.startsWith("@c") || arg.startsWith(".") || arg.startsWith("#") || arg.includes("[")) {
targetSelector = arg;
} else {
outputPath = arg;
}
}
validateOutputPath(outputPath);
if (clipRect && targetSelector) {
throw new Error("Cannot use --clip with a selector/ref — choose one");
}
if (viewportOnly && clipRect) {
throw new Error("Cannot use --viewport with --clip — choose one");
}
if (targetSelector) {
const resolved = await bm.resolveRef(targetSelector);
const locator = "locator" in resolved ? resolved.locator : page.locator(resolved.selector);
await locator.screenshot({ path: outputPath, timeout: 5000 });
return `Screenshot saved (element): ${outputPath}`;
}
if (clipRect) {
await page.screenshot({ path: outputPath, clip: clipRect });
return `Screenshot saved (clip ${clipRect.x},${clipRect.y},${clipRect.width},${clipRect.height}): ${outputPath}`;
}
await page.screenshot({ path: outputPath, fullPage: !viewportOnly });
return `Screenshot saved${viewportOnly ? " (viewport)" : ""}: ${outputPath}`;
}
case "pdf": {
const page = bm.getPage();
const pdfPath = args[0] || `${TEMP_DIR}/browse-page.pdf`;
validateOutputPath(pdfPath);
await page.pdf({ path: pdfPath, format: "A4" });
return `PDF saved: ${pdfPath}`;
}
case "responsive": {
const page = bm.getPage();
const prefix = args[0] || `${TEMP_DIR}/browse-responsive`;
validateOutputPath(prefix);
const viewports = [
{ name: "mobile", width: 375, height: 812 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "desktop", width: 1280, height: 720 }
];
const originalViewport = page.viewportSize();
const results = [];
for (const vp of viewports) {
await page.setViewportSize({ width: vp.width, height: vp.height });
const path6 = `${prefix}-${vp.name}.png`;
await page.screenshot({ path: path6, fullPage: true });
results.push(`${vp.name} (${vp.width}x${vp.height}): ${path6}`);
}
if (originalViewport) {
await page.setViewportSize(originalViewport);
}
return results.join(`
`);
}
case "chain": {
const jsonStr = args[0];
if (!jsonStr)
throw new Error(`Usage: echo '[["goto","url"],["text"]]' | browse chain`);
let commands;
try {
commands = JSON.parse(jsonStr);
} catch {
throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]');
}
if (!Array.isArray(commands))
throw new Error("Expected JSON array of commands");
const results = [];
const { handleReadCommand: handleReadCommand2 } = await Promise.resolve().then(() => (init_read_commands(), exports_read_commands));
const { handleWriteCommand: handleWriteCommand2 } = await Promise.resolve().then(() => (init_write_commands(), exports_write_commands));
for (const cmd of commands) {
const [name, ...cmdArgs] = cmd;
try {
let result;
if (WRITE_COMMANDS.has(name))
result = await handleWriteCommand2(name, cmdArgs, bm);
else if (READ_COMMANDS.has(name))
result = await handleReadCommand2(name, cmdArgs, bm);
else if (META_COMMANDS.has(name))
result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
else
throw new Error(`Unknown command: ${name}`);
results.push(`[${name}] ${result}`);
} catch (err) {
results.push(`[${name}] ERROR: ${err.message}`);
}
}
return results.join(`
`);
}
case "diff": {
const [url1, url2] = args;
if (!url1 || !url2)
throw new Error("Usage: browse diff <url1> <url2>");
const page = bm.getPage();
validateNavigationUrl(url1);
await page.goto(url1, { waitUntil: "domcontentloaded", timeout: 15000 });
const text1 = await getCleanText(page);
validateNavigationUrl(url2);
await page.goto(url2, { waitUntil: "domcontentloaded", timeout: 15000 });
const text2 = await getCleanText(page);
const changes = Diff2.diffLines(text1, text2);
const output = [`--- ${url1}`, `+++ ${url2}`, ""];
for (const part of changes) {
const prefix = part.added ? "+" : part.removed ? "-" : " ";
const lines = part.value.split(`
`).filter((l) => l.length > 0);
for (const line of lines) {
output.push(`${prefix} ${line}`);
}
}
return output.join(`
`);
}
case "snapshot": {
return await handleSnapshot(args, bm);
}
case "handoff": {
const message = args.join(" ") || "User takeover requested";
return await bm.handoff(message);
}
case "resume": {
bm.resume();
const snapshot = await handleSnapshot(["-i"], bm);
return `RESUMED
${snapshot}`;
}
default:
throw new Error(`Unknown meta command: ${command}`);
}
}
// browse/src/cookie-picker-routes.ts
init_cookie_import_browser();
// browse/src/cookie-picker-ui.ts
function getCookiePickerHTML(serverPort) {
const baseUrl = `http://127.0.0.1:${serverPort}`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cookie Import — gstack browse</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #0a0a0a;
color: #e0e0e0;
height: 100vh;
overflow: hidden;
}
/* ─── Header ──────────────────────────── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid #222;
background: #0f0f0f;
}
.header h1 {
font-size: 16px;
font-weight: 600;
color: #fff;
}
.header .port {
font-size: 12px;
color: #666;
font-family: 'SF Mono', 'Fira Code', monospace;
}
/* ─── Layout ──────────────────────────── */
.container {
display: flex;
height: calc(100vh - 53px);
}
.panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-left {
border-right: 1px solid #222;
}
.panel-header {
padding: 16px 20px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #888;
}
/* ─── Browser Pills ───────────────────── */
.browser-pills {
display: flex;
gap: 8px;
padding: 0 20px 12px;
flex-wrap: wrap;
}
.pill {
padding: 6px 14px;
border-radius: 20px;
border: 1px solid #333;
background: #1a1a1a;
color: #aaa;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.pill:hover { border-color: #555; color: #ddd; }
.pill.active {
border-color: #4ade80;
background: #0a2a14;
color: #4ade80;
}
.pill .dot {
width: 6px; height: 6px;
border-radius: 50%;
background: #4ade80;
}
/* ─── Search ──────────────────────────── */
.search-wrap {
padding: 0 20px 12px;
}
.search-input {
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #333;
background: #141414;
color: #e0e0e0;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.search-input::placeholder { color: #555; }
.search-input:focus { border-color: #555; }
/* ─── Domain List ─────────────────────── */
.domain-list {
flex: 1;
overflow-y: auto;
padding: 0 12px;
}
.domain-list::-webkit-scrollbar { width: 6px; }
.domain-list::-webkit-scrollbar-track { background: transparent; }
.domain-list::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
.domain-row {
display: flex;
align-items: center;
padding: 8px 10px;
border-radius: 6px;
transition: background 0.1s;
gap: 8px;
}
.domain-row:hover { background: #1a1a1a; }
.domain-name {
flex: 1;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
color: #ccc;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.domain-count {
font-size: 12px;
color: #666;
font-family: 'SF Mono', 'Fira Code', monospace;
min-width: 28px;
text-align: right;
}
.btn-add, .btn-trash {
width: 28px; height: 28px;
border-radius: 6px;
border: 1px solid #333;
background: #1a1a1a;
color: #888;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
flex-shrink: 0;
}
.btn-add:hover { border-color: #4ade80; color: #4ade80; background: #0a2a14; }
.btn-trash:hover { border-color: #f87171; color: #f87171; background: #2a0a0a; }
.btn-add:disabled, .btn-trash:disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
.btn-add.imported {
border-color: #333;
color: #4ade80;
background: transparent;
cursor: default;
font-size: 14px;
}
/* ─── Footer ──────────────────────────── */
.panel-footer {
padding: 12px 20px;
border-top: 1px solid #222;
font-size: 12px;
color: #666;
}
/* ─── Imported Panel ──────────────────── */
.imported-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #444;
font-size: 13px;
padding: 20px;
text-align: center;
}
/* ─── Banner ──────────────────────────── */
.banner {
padding: 10px 20px;
font-size: 13px;
display: none;
align-items: center;
gap: 10px;
}
.banner.error {
background: #1a0a0a;
border-bottom: 1px solid #3a1111;
color: #f87171;
}
.banner.info {
background: #0a1a2a;
border-bottom: 1px solid #112233;
color: #60a5fa;
}
.banner .banner-text { flex: 1; }
.banner .banner-close, .banner .banner-retry {
background: none;
border: 1px solid currentColor;
color: inherit;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
/* ─── Spinner ─────────────────────────── */
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid #333;
border-top-color: #4ade80;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-row {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
gap: 10px;
color: #666;
font-size: 13px;
}
</style>
</head>
<body>
<div class="header">
<h1>Cookie Import</h1>
<span class="port">localhost:${serverPort}</span>
</div>
<div id="banner" class="banner"></div>
<div class="container">
<!-- Left Panel: Source Browser -->
<div class="panel panel-left">
<div class="panel-header">Source Browser</div>
<div id="browser-pills" class="browser-pills"></div>
<div class="search-wrap">
<input type="text" class="search-input" id="search" placeholder="Search domains..." />
</div>
<div class="domain-list" id="source-domains">
<div class="loading-row"><span class="spinner"></span> Detecting browsers...</div>
</div>
<div class="panel-footer" id="source-footer"></div>
</div>
<!-- Right Panel: Imported -->
<div class="panel panel-right">
<div class="panel-header">Imported to Session</div>
<div class="domain-list" id="imported-domains">
<div class="imported-empty">No cookies imported yet</div>
</div>
<div class="panel-footer" id="imported-footer"></div>
</div>
</div>
<script>
(function() {
const BASE = '${baseUrl}';
let activeBrowser = null;
let allDomains = [];
let importedSet = {}; // domain → count
let inflight = {}; // domain → true (prevents double-click)
const $pills = document.getElementById('browser-pills');
const $search = document.getElementById('search');
const $sourceDomains = document.getElementById('source-domains');
const $importedDomains = document.getElementById('imported-domains');
const $sourceFooter = document.getElementById('source-footer');
const $importedFooter = document.getElementById('imported-footer');
const $banner = document.getElementById('banner');
// ─── Banner ────────────────────────────
function showBanner(msg, type, retryFn) {
$banner.className = 'banner ' + type;
$banner.style.display = 'flex';
let html = '<span class="banner-text">' + escHtml(msg) + '</span>';
if (retryFn) {
html += '<button class="banner-retry" id="banner-retry">Retry</button>';
}
html += '<button class="banner-close" id="banner-close">×</button>';
$banner.innerHTML = html;
document.getElementById('banner-close').onclick = () => { $banner.style.display = 'none'; };
if (retryFn) {
document.getElementById('banner-retry').onclick = () => {
$banner.style.display = 'none';
retryFn();
};
}
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ─── API ────────────────────────────────
async function api(path, opts) {
const res = await fetch(BASE + '/cookie-picker' + path, opts);
const data = await res.json();
if (!res.ok) {
const err = new Error(data.error || 'Request failed');
err.code = data.code;
err.action = data.action;
throw err;
}
return data;
}
// ─── Init ───────────────────────────────
async function init() {
try {
const [browserData, importedData] = await Promise.all([
api('/browsers'),
api('/imported'),
]);
// Populate imported state
for (const entry of importedData.domains) {
importedSet[entry.domain] = entry.count;
}
renderImported();
// Render browser pills
const browsers = browserData.browsers;
if (browsers.length === 0) {
$sourceDomains.innerHTML = '<div class="imported-empty">No Chromium browsers detected</div>';
return;
}
$pills.innerHTML = '';
browsers.forEach(b => {
const pill = document.createElement('button');
pill.className = 'pill';
pill.innerHTML = '<span class="dot"></span>' + escHtml(b.name);
pill.onclick = () => selectBrowser(b.name);
$pills.appendChild(pill);
});
// Auto-select first browser
selectBrowser(browsers[0].name);
} catch (err) {
showBanner(err.message, 'error', init);
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load</div>';
}
}
// ─── Select Browser ────────────────────
async function selectBrowser(name) {
activeBrowser = name;
// Update pills
$pills.querySelectorAll('.pill').forEach(p => {
p.classList.toggle('active', p.textContent === name);
});
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading domains...</div>';
$sourceFooter.textContent = '';
$search.value = '';
try {
const data = await api('/domains?browser=' + encodeURIComponent(name));
allDomains = data.domains;
renderSourceDomains();
} catch (err) {
showBanner(err.message, 'error', err.action === 'retry' ? () => selectBrowser(name) : null);
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load domains</div>';
}
}
// ─── Render Source Domains ─────────────
function renderSourceDomains() {
const query = $search.value.toLowerCase();
const filtered = query
? allDomains.filter(d => d.domain.toLowerCase().includes(query))
: allDomains;
if (filtered.length === 0) {
$sourceDomains.innerHTML = '<div class="imported-empty">' +
(query ? 'No matching domains' : 'No cookie domains found') + '</div>';
$sourceFooter.textContent = '';
return;
}
let html = '';
for (const d of filtered) {
const isImported = d.domain in importedSet;
const isInflight = inflight[d.domain];
html += '<div class="domain-row">';
html += '<span class="domain-name">' + escHtml(d.domain) + '</span>';
html += '<span class="domain-count">' + d.count + '</span>';
if (isInflight) {
html += '<span class="btn-add" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;"></span></span>';
} else if (isImported) {
html += '<span class="btn-add imported">&#10003;</span>';
} else {
html += '<button class="btn-add" data-domain="' + escHtml(d.domain) + '" title="Import">+</button>';
}
html += '</div>';
}
$sourceDomains.innerHTML = html;
// Total counts
const totalDomains = allDomains.length;
const totalCookies = allDomains.reduce((s, d) => s + d.count, 0);
$sourceFooter.textContent = totalDomains + ' domains · ' + totalCookies.toLocaleString() + ' cookies';
// Click handlers
$sourceDomains.querySelectorAll('.btn-add[data-domain]').forEach(btn => {
btn.addEventListener('click', () => importDomain(btn.dataset.domain));
});
}
// ─── Import Domain ─────────────────────
async function importDomain(domain) {
if (inflight[domain] || domain in importedSet) return;
inflight[domain] = true;
renderSourceDomains();
try {
const data = await api('/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ browser: activeBrowser, domains: [domain] }),
});
if (data.domainCounts) {
for (const [d, count] of Object.entries(data.domainCounts)) {
importedSet[d] = (importedSet[d] || 0) + count;
}
}
renderImported();
} catch (err) {
showBanner('Import failed for ' + domain + ': ' + err.message, 'error',
err.action === 'retry' ? () => importDomain(domain) : null);
} finally {
delete inflight[domain];
renderSourceDomains();
}
}
// ─── Render Imported ───────────────────
function renderImported() {
const entries = Object.entries(importedSet).sort((a, b) => b[1] - a[1]);
if (entries.length === 0) {
$importedDomains.innerHTML = '<div class="imported-empty">No cookies imported yet</div>';
$importedFooter.textContent = '';
return;
}
let html = '';
for (const [domain, count] of entries) {
const isInflight = inflight['remove:' + domain];
html += '<div class="domain-row">';
html += '<span class="domain-name">' + escHtml(domain) + '</span>';
html += '<span class="domain-count">' + count + '</span>';
if (isInflight) {
html += '<span class="btn-trash" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;border-top-color:#f87171;"></span></span>';
} else {
html += '<button class="btn-trash" data-domain="' + escHtml(domain) + '" title="Remove">&#128465;</button>';
}
html += '</div>';
}
$importedDomains.innerHTML = html;
const totalCookies = entries.reduce((s, e) => s + e[1], 0);
$importedFooter.textContent = entries.length + ' domains · ' + totalCookies.toLocaleString() + ' cookies imported';
// Click handlers
$importedDomains.querySelectorAll('.btn-trash[data-domain]').forEach(btn => {
btn.addEventListener('click', () => removeDomain(btn.dataset.domain));
});
}
// ─── Remove Domain ─────────────────────
async function removeDomain(domain) {
if (inflight['remove:' + domain]) return;
inflight['remove:' + domain] = true;
renderImported();
try {
await api('/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domains: [domain] }),
});
delete importedSet[domain];
renderImported();
renderSourceDomains(); // update checkmarks
} catch (err) {
showBanner('Remove failed for ' + domain + ': ' + err.message, 'error',
err.action === 'retry' ? () => removeDomain(domain) : null);
} finally {
delete inflight['remove:' + domain];
renderImported();
}
}
// ─── Search ────────────────────────────
$search.addEventListener('input', renderSourceDomains);
// ─── Start ─────────────────────────────
init();
})();
</script>
</body>
</html>`;
}
// browse/src/cookie-picker-routes.ts
var importedDomains = new Set;
var importedCounts = new Map;
function corsOrigin(port) {
return `http://127.0.0.1:${port}`;
}
function jsonResponse(data, opts) {
return new Response(JSON.stringify(data), {
status: opts.status ?? 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": corsOrigin(opts.port)
}
});
}
function errorResponse(message, code, opts) {
return jsonResponse({ error: message, code, ...opts.action ? { action: opts.action } : {} }, { port: opts.port, status: opts.status ?? 400 });
}
async function handleCookiePickerRoute(url, req, bm) {
const pathname = url.pathname;
const port = parseInt(url.port, 10) || 9400;
if (req.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": corsOrigin(port),
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type"
}
});
}
try {
if (pathname === "/cookie-picker" && req.method === "GET") {
const html = getCookiePickerHTML(port);
return new Response(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" }
});
}
if (pathname === "/cookie-picker/browsers" && req.method === "GET") {
const browsers = findInstalledBrowsers();
return jsonResponse({
browsers: browsers.map((b) => ({
name: b.name,
aliases: b.aliases
}))
}, { port });
}
if (pathname === "/cookie-picker/domains" && req.method === "GET") {
const browserName = url.searchParams.get("browser");
if (!browserName) {
return errorResponse("Missing 'browser' parameter", "missing_param", { port });
}
const result = listDomains(browserName);
return jsonResponse({
browser: result.browser,
domains: result.domains
}, { port });
}
if (pathname === "/cookie-picker/import" && req.method === "POST") {
let body;
try {
body = await req.json();
} catch {
return errorResponse("Invalid JSON body", "bad_request", { port });
}
const { browser, domains } = body;
if (!browser)
return errorResponse("Missing 'browser' field", "missing_param", { port });
if (!domains || !Array.isArray(domains) || domains.length === 0) {
return errorResponse("Missing or empty 'domains' array", "missing_param", { port });
}
const result = await importCookies(browser, domains);
if (result.cookies.length === 0) {
return jsonResponse({
imported: 0,
failed: result.failed,
domainCounts: {},
message: result.failed > 0 ? `All ${result.failed} cookies failed to decrypt` : "No cookies found for the specified domains"
}, { port });
}
const page = bm.getPage();
await page.context().addCookies(result.cookies);
for (const domain of Object.keys(result.domainCounts)) {
importedDomains.add(domain);
importedCounts.set(domain, (importedCounts.get(domain) || 0) + result.domainCounts[domain]);
}
console.log(`[cookie-picker] Imported ${result.count} cookies for ${Object.keys(result.domainCounts).length} domains`);
return jsonResponse({
imported: result.count,
failed: result.failed,
domainCounts: result.domainCounts
}, { port });
}
if (pathname === "/cookie-picker/remove" && req.method === "POST") {
let body;
try {
body = await req.json();
} catch {
return errorResponse("Invalid JSON body", "bad_request", { port });
}
const { domains } = body;
if (!domains || !Array.isArray(domains) || domains.length === 0) {
return errorResponse("Missing or empty 'domains' array", "missing_param", { port });
}
const page = bm.getPage();
const context = page.context();
for (const domain of domains) {
await context.clearCookies({ domain });
importedDomains.delete(domain);
importedCounts.delete(domain);
}
console.log(`[cookie-picker] Removed cookies for ${domains.length} domains`);
return jsonResponse({
removed: domains.length,
domains
}, { port });
}
if (pathname === "/cookie-picker/imported" && req.method === "GET") {
const entries = [];
for (const domain of importedDomains) {
entries.push({ domain, count: importedCounts.get(domain) || 0 });
}
entries.sort((a, b) => b.count - a.count);
return jsonResponse({
domains: entries,
totalDomains: entries.length,
totalCookies: entries.reduce((sum, e) => sum + e.count, 0)
}, { port });
}
return new Response("Not found", { status: 404 });
} catch (err) {
if (err instanceof CookieImportError) {
return errorResponse(err.message, err.code, { port, status: 400, action: err.action });
}
console.error(`[cookie-picker] Error: ${err.message}`);
return errorResponse(err.message || "Internal error", "internal_error", { port, status: 500 });
}
}
// browse/src/config.ts
import * as fs4 from "fs";
import * as path6 from "path";
function getGitRoot() {
try {
const proc = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
stdout: "pipe",
stderr: "pipe",
timeout: 2000
});
if (proc.exitCode !== 0)
return null;
return proc.stdout.toString().trim() || null;
} catch {
return null;
}
}
function resolveConfig(env = process.env) {
let stateFile;
let stateDir;
let projectDir;
if (env.BROWSE_STATE_FILE) {
stateFile = env.BROWSE_STATE_FILE;
stateDir = path6.dirname(stateFile);
projectDir = path6.dirname(stateDir);
} else {
projectDir = getGitRoot() || process.cwd();
stateDir = path6.join(projectDir, ".gstack");
stateFile = path6.join(stateDir, "browse.json");
}
return {
projectDir,
stateDir,
stateFile,
consoleLog: path6.join(stateDir, "browse-console.log"),
networkLog: path6.join(stateDir, "browse-network.log"),
dialogLog: path6.join(stateDir, "browse-dialog.log")
};
}
function ensureStateDir(config) {
try {
fs4.mkdirSync(config.stateDir, { recursive: true });
} catch (err) {
if (err.code === "EACCES") {
throw new Error(`Cannot create state directory ${config.stateDir}: permission denied`);
}
if (err.code === "ENOTDIR") {
throw new Error(`Cannot create state directory ${config.stateDir}: a file exists at that path`);
}
throw err;
}
const gitignorePath = path6.join(config.projectDir, ".gitignore");
try {
const content = fs4.readFileSync(gitignorePath, "utf-8");
if (!content.match(/^\.gstack\/?$/m)) {
const separator = content.endsWith(`
`) ? "" : `
`;
fs4.appendFileSync(gitignorePath, `${separator}.gstack/
`);
}
} catch (err) {
if (err.code !== "ENOENT") {
const logPath = path6.join(config.stateDir, "browse-server.log");
try {
fs4.appendFileSync(logPath, `[${new Date().toISOString()}] Warning: could not update .gitignore at ${gitignorePath}: ${err.message}
`);
} catch {}
}
}
}
function readVersionHash(execPath = process.execPath) {
try {
const versionFile = path6.resolve(path6.dirname(execPath), ".version");
return fs4.readFileSync(versionFile, "utf-8").trim() || null;
} catch {
return null;
}
}
// browse/src/server.ts
init_buffers();
import * as fs5 from "fs";
import * as path7 from "path";
import * as crypto2 from "crypto";
var config = resolveConfig();
ensureStateDir(config);
var AUTH_TOKEN = crypto2.randomUUID();
var BROWSE_PORT = parseInt(process.env.BROWSE_PORT || "0", 10);
var IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || "1800000", 10);
function validateAuth(req) {
const header = req.headers.get("authorization");
return header === `Bearer ${AUTH_TOKEN}`;
}
function generateHelpText() {
const groups = new Map;
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(", ")}`);
}
lines.push("");
lines.push("Snapshot flags:");
const flagPairs = [];
for (const flag of SNAPSHOT_FLAGS) {
const label = flag.valueHint ? `${flag.short} ${flag.valueHint}` : flag.short;
flagPairs.push(`${label} ${flag.long}`);
}
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(`
`);
}
var CONSOLE_LOG_PATH = config.consoleLog;
var NETWORK_LOG_PATH = config.networkLog;
var DIALOG_LOG_PATH = config.dialogLog;
var lastConsoleFlushed = 0;
var lastNetworkFlushed = 0;
var lastDialogFlushed = 0;
var flushInProgress = false;
async function flushBuffers() {
if (flushInProgress)
return;
flushInProgress = true;
try {
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(`
`) + `
`;
fs5.appendFileSync(CONSOLE_LOG_PATH, lines);
lastConsoleFlushed = consoleBuffer.totalAdded;
}
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(`
`) + `
`;
fs5.appendFileSync(NETWORK_LOG_PATH, lines);
lastNetworkFlushed = networkBuffer.totalAdded;
}
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(`
`) + `
`;
fs5.appendFileSync(DIALOG_LOG_PATH, lines);
lastDialogFlushed = dialogBuffer.totalAdded;
}
} catch {} finally {
flushInProgress = false;
}
}
var flushInterval = setInterval(flushBuffers, 1000);
var lastActivity = Date.now();
function resetIdleTimer() {
lastActivity = Date.now();
}
var idleCheckInterval = setInterval(() => {
if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`);
shutdown();
}
}, 60000);
var browserManager = new BrowserManager;
var isShuttingDown = false;
async function findPort() {
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`);
}
}
const MIN_PORT = 1e4;
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}`);
}
function wrapError(err) {
const msg = err.message || String(err);
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(`
`)[0]}`;
}
if (msg.includes("resolved to") && msg.includes("elements")) {
return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`;
}
return msg;
}
async function handleCommand(body) {
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;
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) {
browserManager.incrementFailures();
let errorMsg = wrapError(err);
const hint = browserManager.getFailureHint();
if (hint)
errorMsg += `
` + 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();
await browserManager.close();
try {
fs5.unlinkSync(config.stateFile);
} catch {}
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
async function start() {
try {
fs5.unlinkSync(CONSOLE_LOG_PATH);
} catch {}
try {
fs5.unlinkSync(NETWORK_LOG_PATH);
} catch {}
try {
fs5.unlinkSync(DIALOG_LOG_PATH);
} catch {}
const port = await findPort();
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);
if (url.pathname.startsWith("/cookie-picker")) {
return handleCookiePickerRoute(url, req, browserManager);
}
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" }
});
}
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 });
}
});
const state = {
pid: process.pid,
port,
token: AUTH_TOKEN,
startedAt: new Date().toISOString(),
serverPath: path7.resolve(__browseNodeSrcDir, "server.ts"),
binaryVersion: readVersionHash() || undefined
};
const tmpFile = config.stateFile + ".tmp";
fs5.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 384 });
fs5.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);
});
export {
networkBuffer,
dialogBuffer,
consoleBuffer,
addNetworkEntry,
addDialogEntry,
addConsoleEntry,
WRITE_COMMANDS,
READ_COMMANDS,
META_COMMANDS
};