Files
gstack/browse/src/write-commands.ts
T
Garry Tan f7b95329c1 feat: Phase 3.5 — cookie import, QA testing, team retro (v0.3.1) (#29)
* Phase 2: Enhanced browser — dialog handling, upload, state checks, snapshots

- CircularBuffer O(1) ring buffer for console/network/dialog (was O(n) array+shift)
- Async buffer flush with Bun.write() (was appendFileSync)
- Dialog auto-accept/dismiss with buffer + prompt text support
- File upload command (upload <sel> <file...>)
- Element state checks (is visible/hidden/enabled/disabled/checked/editable/focused)
- Annotated screenshots with ref labels overlaid (-a flag)
- Snapshot diffing against previous snapshot (-D flag)
- Cursor-interactive element scan for non-ARIA clickables (-C flag)
- Snapshot scoping depth limit (-d N flag)
- Health check with page.evaluate + 2s timeout
- Playwright error wrapping — actionable messages for AI agents
- Fix useragent — context recreation preserves cookies/storage/URLs
- wait --networkidle / --load / --domcontentloaded flags
- console --errors filter (error + warning only)
- cookie-import <json-file> with auto-fill domain from page URL
- 166 integration tests (was ~63)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Phase 2: Rewrite SKILL.md as QA playbook + command reference

Reorient SKILL.md files from raw command reference to QA-first playbook
with 10 workflow patterns (test user flows, verify deployments, dogfood
features, responsive layouts, file upload, forms, dialogs, compare pages).
Compact command reference tables at the bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Phase 3: /qa skill — systematic QA testing with health scores

New /qa skill for systematic web app QA testing. Three modes:
- full: 5-10 documented issues with screenshots and repro steps
- quick: 30-second smoke test with health score
- regression: compare against saved baseline

Includes issue taxonomy (7 categories, 4 severity levels), structured
report template, health score rubric (weighted across 7 categories),
framework detection guidance (Next.js, Rails, WordPress, SPA).

Also adds browse/bin/find-browse (DRY binary discovery using git
rev-parse), .gstack/ to .gitignore, and updated TODO roadmap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Bump to v0.3.0 — Phase 2 + Phase 3 changelog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: cookie-import-browser — Chromium cookie decryption module + tests

Pure logic module for reading and decrypting cookies from macOS Chromium
browsers (Comet, Chrome, Arc, Brave, Edge). Supports v10 AES-128-CBC
encryption with macOS Keychain access, PBKDF2 key derivation, and
per-browser key caching. 18 unit tests with encrypted cookie fixtures.

* feat: cookie picker web UI + route handler

Two-panel dark-theme picker served from the browse server. Left panel
shows source browser domains with search and import buttons. Right panel
shows imported domains with trash buttons. No cookie values exposed.
6 API endpoints, importedDomains Set tracking, inline clearCookies.

* feat: wire cookie-import-browser into browse server

Add cookie-picker route dispatch (no auth, localhost-only), add
cookie-import-browser to WRITE_COMMANDS and CHAIN_WRITE, add serverPort
property to BrowserManager, add write command with two modes (picker UI
vs --domain direct import), update CLI help text.

* chore: /setup-browser-cookies skill + docs (Phase 3.5)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: bump version and changelog (v0.3.1)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* security: redact sensitive values from command output (PR #21)

type no longer echoes text (reports character count), cookie redacts
value with ****, header redacts Authorization/Cookie/X-API-Key/X-Auth-Token,
storage set drops value, forms redacts password fields. Prevents secrets
from persisting in LLM transcripts. 7 new tests.

Credit: fredluz (PR #21)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* security: path traversal prevention for screenshot/pdf/eval (PR #26)

Add validateOutputPath() for screenshot/pdf/responsive (restricts to
/tmp and cwd) and validateReadPath() for eval (blocks .. sequences and
absolute paths outside safe dirs). 7 new tests.

Credit: Jah-yee (PR #26)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: auto-install Playwright Chromium in setup (PR #22)

Setup now verifies Playwright can launch Chromium, and auto-installs
it via `bunx playwright install chromium` if missing. Exits non-zero
if build or Chromium launch fails.

Credit: AkbarDevop (PR #22)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* security: fix path validation bypass, CORS restriction, cookie-import path check

- startsWith('/tmp') matched '/tmpevil' — now requires trailing slash
- CORS Access-Control-Allow-Origin changed from * to http://127.0.0.1:<port>
- cookie-import now validates file paths (was missing validateReadPath)
- 3 new tests for prefix collision and cookie-import path traversal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review informational issues + add regression tests

- Add cookie-import to CHAIN_WRITE set for chain command routing
- Add path validation to snapshot -a -o output path
- Fix package.json version to match 0.3.1
- Use crypto.randomUUID() for temp DB paths (unpredictable filenames)
- Add regression tests for chain cookie-import and snapshot path validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add /qa, /setup-browser-cookies to README + update BROWSER.md

- Add /qa and /setup-browser-cookies to skills table, install/update/uninstall blurbs
- Add dedicated README sections for both new skills with usage examples
- Update demo workflow to show cookie import → QA → browse flow
- Update BROWSER.md: cookie import commands, new source files, test count (203)
- Update skill count from 6 to 8

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: team-aware /retro v2.0 — per-person praise and growth opportunities

- Identify current user via git config, orient narrative as "you" vs teammates
- Add per-author metrics: commits, LOC, focus areas, commit type mix, sessions
- New "Your Week" section with personal deep-dive for whoever runs the command
- New "Team Breakdown" with per-person praise and growth opportunities
- Track AI-assisted commits via Co-Authored-By trailers
- Personal + team shipping streaks
- Tone: praise like a 1:1, growth like investment advice, never compare negatively

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add Conductor parallel sessions section to README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:31:41 -07:00

313 lines
11 KiB
TypeScript

/**
* Write commands — navigate and interact with pages (side effects)
*
* goto, back, forward, reload, click, fill, select, hover, type,
* press, scroll, wait, viewport, cookie, header, useragent
*/
import type { BrowserManager } from './browser-manager';
import { findInstalledBrowsers, importCookies } from './cookie-import-browser';
import * as fs from 'fs';
import * as path from 'path';
export async function handleWriteCommand(
command: string,
args: string[],
bm: BrowserManager
): Promise<string> {
const page = bm.getPage();
switch (command) {
case 'goto': {
const url = args[0];
if (!url) throw new Error('Usage: browse goto <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 resolved = bm.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.click({ timeout: 5000 });
} else {
await page.click(resolved.selector, { timeout: 5000 });
}
// Wait briefly for any navigation/DOM update
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 = 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 = 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 = 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 = 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 timeout = args[1] ? parseInt(args[1], 10) : 15000;
await page.waitForLoadState('networkidle', { timeout });
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 = 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 sep = headerStr.indexOf(':');
const name = headerStr.slice(0, sep).trim();
const value = headerStr.slice(sep + 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...]');
// Validate all files exist before upload
for (const fp of filePaths) {
if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`);
}
const resolved = 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 = fs.statSync(fp);
return `${path.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>');
// Path validation — prevent reading arbitrary files
if (path.isAbsolute(filePath)) {
const safeDirs = ['/tmp', process.cwd()];
const resolved = path.resolve(filePath);
if (!safeDirs.some(dir => resolved === dir || resolved.startsWith(dir + '/'))) {
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
}
}
if (path.normalize(filePath).includes('..')) {
throw new Error('Path traversal sequences (..) are not allowed');
}
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
const raw = fs.readFileSync(filePath, 'utf-8');
let cookies: any[];
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');
// Auto-fill domain from current page URL when missing (consistent with cookie command)
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': {
// Two modes:
// 1. Direct CLI import: cookie-import-browser <browser> --domain <domain>
// 2. Open picker UI: cookie-import-browser [browser]
const browserArg = args[0];
const domainIdx = args.indexOf('--domain');
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
// Direct import mode — no UI
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(' ');
}
// Picker UI mode — open in user's browser
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 {
// open may fail silently — URL is in the message below
}
return `Cookie picker opened at ${pickerUrl}\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`;
}
default:
throw new Error(`Unknown write command: ${command}`);
}
}