Files
gstack/browse/src/read-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

295 lines
10 KiB
TypeScript

/**
* Read commands — extract data from pages without side effects
*
* text, html, links, forms, accessibility, js, eval, css, attrs,
* console, network, cookies, storage, perf
*/
import type { BrowserManager } from './browser-manager';
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
import type { Page } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
// Security: Path validation to prevent path traversal attacks
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
function validateReadPath(filePath: string): void {
if (path.isAbsolute(filePath)) {
const resolved = path.resolve(filePath);
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
if (!isSafe) {
throw new Error(`Absolute path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
}
const normalized = path.normalize(filePath);
if (normalized.includes('..')) {
throw new Error('Path traversal sequences (..) are not allowed');
}
}
/**
* Extract clean text from a page (strips script/style/noscript/svg).
* Exported for DRY reuse in meta-commands (diff).
*/
export async function getCleanText(page: Page): Promise<string> {
return await page.evaluate(() => {
const body = document.body;
if (!body) return '';
const clone = body.cloneNode(true) as HTMLElement;
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
return clone.innerText
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.join('\n');
});
}
export async function handleReadCommand(
command: string,
args: string[],
bm: BrowserManager
): Promise<string> {
const page = bm.getPage();
switch (command) {
case 'text': {
return await getCleanText(page);
}
case 'html': {
const selector = args[0];
if (selector) {
const resolved = 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 as HTMLAnchorElement).href,
})).filter(l => l.text && l.href)
);
return links.map(l => `${l.text}${l.href}`).join('\n');
}
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 as HTMLInputElement;
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 as HTMLSelectElement).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 result = await page.evaluate(expr);
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 result = await page.evaluate(code);
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 = bm.resolveRef(selector);
if ('locator' in resolved) {
const value = await resolved.locator.evaluate(
(el, prop) => getComputedStyle(el).getPropertyValue(prop),
property
);
return value;
}
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 = bm.resolveRef(selector);
if ('locator' in resolved) {
const attrs = await resolved.locator.evaluate((el) => {
const result: Record<string, string> = {};
for (const attr of el.attributes) {
result[attr.name] = attr.value;
}
return result;
});
return JSON.stringify(attrs, null, 2);
}
const attrs = await page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return `Element not found: ${sel}`;
const result: Record<string, string> = {};
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('\n');
}
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('\n');
}
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('\n');
}
case 'is': {
const property = args[0];
const selector = args[1];
if (!property || !selector) throw new Error('Usage: browse is <property> <selector>\nProperties: visible, hidden, enabled, disabled, checked, editable, focused');
const resolved = 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] as PerformanceNavigationTiming;
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('\n');
}
default:
throw new Error(`Unknown read command: ${command}`);
}
}