Merge remote-tracking branch 'origin/main' into garrytan/usage-telemetry

# Conflicts:
#	SKILL.md
#	TODOS.md
#	browse/SKILL.md
#	design-consultation/SKILL.md
#	design-review/SKILL.md
#	document-release/SKILL.md
#	plan-ceo-review/SKILL.md
#	plan-design-review/SKILL.md
#	plan-eng-review/SKILL.md
#	qa-only/SKILL.md
#	qa/SKILL.md
#	retro/SKILL.md
#	retro/SKILL.md.tmpl
#	review/SKILL.md
#	scripts/gen-skill-docs.ts
#	setup-browser-cookies/SKILL.md
#	ship/SKILL.md
This commit is contained in:
Garry Tan
2026-03-19 00:50:11 -07:00
81 changed files with 8178 additions and 609 deletions
+59 -6
View File
@@ -40,7 +40,8 @@ _SESSION_ID="$$-$(date +%s)"
echo "TELEMETRY: ${_TEL:-off}"
echo "TEL_PROMPTED: $_TEL_PROMPTED"
mkdir -p ~/.gstack/analytics
for _PF in ~/.gstack/analytics/.pending-* 2>/dev/null; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done
echo '{"skill":"browse","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
for _PF in ~/.gstack/analytics/.pending-*; do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done
```
If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke
@@ -155,13 +156,37 @@ Hey gstack team — ran into this while using /{skill-name}:
Slug: lowercase, hyphens, max 60 chars (e.g. `browse-js-no-await`). Skip if file already exists. Max 3 reports per session. File inline and continue — don't stop the workflow. Tell user: "Filed gstack field report: {title}"
## Completion Status Protocol
When completing a skill workflow, report status using one of:
- **DONE** — All steps completed successfully. Evidence provided for each claim.
- **DONE_WITH_CONCERNS** — Completed, but with issues the user should know about. List each concern.
- **BLOCKED** — Cannot proceed. State what is blocking and what was tried.
- **NEEDS_CONTEXT** — Missing information required to continue. State exactly what you need.
### Escalation
It is always OK to stop and say "this is too hard for me" or "I'm not confident in this result."
Bad work is worse than no work. You will not be penalized for escalating.
- If you have attempted a task 3 times without success, STOP and escalate.
- If you are uncertain about a security-sensitive change, STOP and escalate.
- If the scope of work exceeds what you can verify, STOP and escalate.
Escalation format:
```
STATUS: BLOCKED | NEEDS_CONTEXT
REASON: [1-2 sentences]
ATTEMPTED: [what you tried]
RECOMMENDATION: [what the user should do next]
```
## Telemetry (run last)
After the skill workflow completes (success, error, or abort), write the .pending marker
with the actual skill name, then log the telemetry event. Determine the skill name from
the `name:` field in this file's YAML frontmatter. Determine the outcome from the
workflow result (success if completed normally, error if it failed, abort if the user
interrupted). Run this bash:
After the skill workflow completes (success, error, or abort), log the telemetry event.
Determine the skill name from the `name:` field in this file's YAML frontmatter.
Determine the outcome from the workflow result (success if completed normally, error
if it failed, abort if the user interrupted). Run this bash:
```bash
_TEL_END=$(date +%s)
@@ -283,6 +308,32 @@ $B diff https://staging.app.com https://prod.app.com
### 11. Show screenshots to the user
After `$B screenshot`, `$B snapshot -a -o`, or `$B responsive`, always use the Read tool on the output PNG(s) so the user can see them. Without this, screenshots are invisible.
## User Handoff
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
login), hand off to the user:
```bash
# 1. Open a visible Chrome at the current page
$B handoff "Stuck on CAPTCHA at login page"
# 2. Tell the user what happened (via AskUserQuestion)
# "I've opened Chrome at the login page. Please solve the CAPTCHA
# and let me know when you're done."
# 3. When user says "done", re-snapshot and continue
$B resume
```
**When to use handoff:**
- CAPTCHAs or bot detection
- Multi-factor authentication (SMS, authenticator app)
- OAuth flows that require user interaction
- Complex interactions the AI can't handle after 3 attempts
The browser preserves all state (cookies, localStorage, tabs) across the handoff.
After `resume`, you get a fresh snapshot of wherever the user left off.
## Snapshot Flags
The snapshot is your primary tool for understanding and interacting with pages.
@@ -405,6 +456,8 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
### Server
| Command | Description |
|---------|-------------|
| `handoff [message]` | Open visible Chrome at current page for user takeover |
| `restart` | Restart server |
| `resume` | Re-snapshot after user takeover, return control to AI |
| `status` | Health check |
| `stop` | Shutdown server |
+26
View File
@@ -106,6 +106,32 @@ $B diff https://staging.app.com https://prod.app.com
### 11. Show screenshots to the user
After `$B screenshot`, `$B snapshot -a -o`, or `$B responsive`, always use the Read tool on the output PNG(s) so the user can see them. Without this, screenshots are invisible.
## User Handoff
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
login), hand off to the user:
```bash
# 1. Open a visible Chrome at the current page
$B handoff "Stuck on CAPTCHA at login page"
# 2. Tell the user what happened (via AskUserQuestion)
# "I've opened Chrome at the login page. Please solve the CAPTCHA
# and let me know when you're done."
# 3. When user says "done", re-snapshot and continue
$B resume
```
**When to use handoff:**
- CAPTCHAs or bot detection
- Multi-factor authentication (SMS, authenticator app)
- OAuth flows that require user interaction
- Complex interactions the AI can't handle after 3 attempts
The browser preserves all state (cookies, localStorage, tabs) across the handoff.
After `resume`, you get a fresh snapshot of wherever the user left off.
## Snapshot Flags
{{SNAPSHOT_FLAGS}}
+227 -68
View File
@@ -15,8 +15,9 @@
* restores state. Falls back to clean slate on any failure.
*/
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator } from 'playwright';
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
import { validateNavigationUrl } from './url-validation';
export interface RefEntry {
locator: Locator;
@@ -24,6 +25,15 @@ export interface RefEntry {
name: string;
}
export interface BrowserState {
cookies: Cookie[];
pages: Array<{
url: string;
isActive: boolean;
storage: { localStorage: Record<string, string>; sessionStorage: Record<string, string> } | null;
}>;
}
export class BrowserManager {
private browser: Browser | null = null;
private context: BrowserContext | null = null;
@@ -47,6 +57,10 @@ export class BrowserManager {
private dialogAutoAccept: boolean = true;
private dialogPromptText: string | null = null;
// ─── Handoff State ─────────────────────────────────────────
private isHeaded: boolean = false;
private consecutiveFailures: number = 0;
async launch() {
this.browser = await chromium.launch({ headless: true });
@@ -77,7 +91,11 @@ export class BrowserManager {
if (this.browser) {
// Remove disconnect handler to avoid exit during intentional close
this.browser.removeAllListeners('disconnected');
await this.browser.close();
// Timeout: headed browser.close() can hang on macOS
await Promise.race([
this.browser.close(),
new Promise(resolve => setTimeout(resolve, 5000)),
]).catch(() => {});
this.browser = null;
}
}
@@ -102,6 +120,11 @@ export class BrowserManager {
async newTab(url?: string): Promise<number> {
if (!this.context) throw new Error('Browser not launched');
// Validate URL before allocating page to avoid zombie tabs on rejection
if (url) {
validateNavigationUrl(url);
}
const page = await this.context.newPage();
const id = this.nextTabId++;
this.pages.set(id, page);
@@ -269,6 +292,92 @@ export class BrowserManager {
return this.customUserAgent;
}
// ─── State Save/Restore (shared by recreateContext + handoff) ─
/**
* Capture browser state: cookies, localStorage, sessionStorage, URLs, active tab.
* Skips pages that fail storage reads (e.g., already closed).
*/
async saveState(): Promise<BrowserState> {
if (!this.context) throw new Error('Browser not launched');
const cookies = await this.context.cookies();
const pages: BrowserState['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 };
}
/**
* Restore browser state into the current context: cookies, pages, storage.
* Navigates to saved URLs, restores storage, wires page events.
* Failures on individual pages are swallowed — partial restore is better than none.
*/
async restoreState(state: BrowserState): Promise<void> {
if (!this.context) throw new Error('Browser not launched');
// Restore cookies
if (state.cookies.length > 0) {
await this.context.addCookies(state.cookies);
}
// Re-create pages
let activeId: number | null = 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: { localStorage: Record<string, string>; sessionStorage: Record<string, string> }) => {
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 no pages were saved, create a blank one
if (this.pages.size === 0) {
await this.newTab();
} else {
this.activeTabId = activeId ?? [...this.pages.keys()][0];
}
// Clear refs — pages are new, locators are stale
this.clearRefs();
}
/**
* Recreate the browser context to apply user agent changes.
* Saves and restores cookies, localStorage, sessionStorage, and open pages.
@@ -280,25 +389,8 @@ export class BrowserManager {
}
try {
// 1. Save state from current context
const savedCookies = await this.context.cookies();
const savedPages: Array<{ url: string; isActive: boolean; storage: { localStorage: Record<string, string>; sessionStorage: Record<string, string> } | null }> = [];
for (const [id, page] of this.pages) {
const url = page.url();
let storage = null;
try {
storage = await page.evaluate(() => ({
localStorage: { ...localStorage },
sessionStorage: { ...sessionStorage },
}));
} catch {}
savedPages.push({
url: url === 'about:blank' ? '' : url,
isActive: id === this.activeTabId,
storage,
});
}
// 1. Save state
const state = await this.saveState();
// 2. Close old pages and context
for (const page of this.pages.values()) {
@@ -320,53 +412,8 @@ export class BrowserManager {
await this.context.setExtraHTTPHeaders(this.extraHeaders);
}
// 4. Restore cookies
if (savedCookies.length > 0) {
await this.context.addCookies(savedCookies);
}
// 5. Re-create pages
let activeId: number | null = null;
for (const saved of savedPages) {
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(() => {});
}
// 6. Restore storage
if (saved.storage) {
try {
await page.evaluate((s: { localStorage: Record<string, string>; sessionStorage: Record<string, string> }) => {
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 no pages were saved, create a blank one
if (this.pages.size === 0) {
await this.newTab();
} else {
this.activeTabId = activeId ?? [...this.pages.keys()][0];
}
// Clear refs — pages are new, locators are stale
this.clearRefs();
// 4. Restore state
await this.restoreState(state);
return null; // success
} catch (err: unknown) {
@@ -391,6 +438,118 @@ export class BrowserManager {
}
}
// ─── Handoff: Headless → Headed ─────────────────────────────
/**
* Hand off browser control to the user by relaunching in headed mode.
*
* Flow (launch-first-close-second for safe rollback):
* 1. Save state from current headless browser
* 2. Launch NEW headed browser
* 3. Restore state into new browser
* 4. Close OLD headless browser
* If step 2 fails → return error, headless browser untouched
*/
async handoff(message: string): Promise<string> {
if (this.isHeaded) {
return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`;
}
if (!this.browser || !this.context) {
throw new Error('Browser not launched');
}
// 1. Save state from current browser
const state = await this.saveState();
const currentUrl = this.getCurrentUrl();
// 2. Launch new headed browser (try-catch — if this fails, headless stays running)
let newBrowser: Browser;
try {
newBrowser = await chromium.launch({ headless: false, timeout: 15000 });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return `ERROR: Cannot open headed browser — ${msg}. Headless browser still running.`;
}
// 3. Create context and restore state into new headed browser
try {
const contextOptions: BrowserContextOptions = {
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);
}
// Swap to new browser/context before restoreState (it uses this.context)
const oldBrowser = this.browser;
const oldContext = this.context;
this.browser = newBrowser;
this.context = newContext;
this.pages.clear();
// Register crash handler on new browser
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;
// 4. Close old headless browser (fire-and-forget — close() can hang
// when another Playwright instance is active, so we don't await it)
oldBrowser.removeAllListeners('disconnected');
oldBrowser.close().catch(() => {});
return [
`HANDOFF: Browser opened at ${currentUrl}`,
`MESSAGE: ${message}`,
`STATUS: Waiting for user. Run 'resume' when done.`,
].join('\n');
} catch (err: unknown) {
// Restore failed — close the new browser, keep old one
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 AI control after user handoff.
* Clears stale refs and resets failure counter.
* The meta-command handler calls handleSnapshot() after this.
*/
resume(): void {
this.clearRefs();
this.resetFailures();
}
getIsHeaded(): boolean {
return this.isHeaded;
}
// ─── Auto-handoff Hint (consecutive failure tracking) ───────
incrementFailures(): void {
this.consecutiveFailures++;
}
resetFailures(): void {
this.consecutiveFailures = 0;
}
getFailureHint(): string | null {
if (this.consecutiveFailures >= 3 && !this.isHeaded) {
return `HINT: ${this.consecutiveFailures} consecutive failures. Consider using 'handoff' to let the user help.`;
}
return null;
}
// ─── Console/Network/Dialog/Ref Wiring ────────────────────
private wirePageEvents(page: Page) {
// Clear ref map on navigation — refs point to stale elements after page change
+4
View File
@@ -30,6 +30,7 @@ export const META_COMMANDS = new Set([
'screenshot', 'pdf', 'responsive',
'chain', 'diff',
'url', 'snapshot',
'handoff', 'resume',
]);
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
@@ -94,6 +95,9 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
// Meta
'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
'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' },
};
// Load-time validation: descriptions must cover exactly the command sets
+17 -1
View File
@@ -6,6 +6,7 @@ import type { BrowserManager } from './browser-manager';
import { handleSnapshot } from './snapshot';
import { getCleanText } from './read-commands';
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
import { validateNavigationUrl } from './url-validation';
import * as Diff from 'diff';
import * as fs from 'fs';
import * as path from 'path';
@@ -13,7 +14,7 @@ import * as path from 'path';
// Security: Path validation to prevent path traversal attacks
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
function validateOutputPath(filePath: string): void {
export function validateOutputPath(filePath: string): void {
const resolved = path.resolve(filePath);
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
if (!isSafe) {
@@ -221,9 +222,11 @@ export async function handleMetaCommand(
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);
@@ -246,6 +249,19 @@ export async function handleMetaCommand(
return await handleSnapshot(args, bm);
}
// ─── Handoff ────────────────────────────────────
case 'handoff': {
const message = args.join(' ') || 'User takeover requested';
return await bm.handoff(message);
}
case 'resume': {
bm.resume();
// Re-snapshot to capture current page state after human interaction
const snapshot = await handleSnapshot(['-i'], bm);
return `RESUMED\n${snapshot}`;
}
default:
throw new Error(`Unknown meta command: ${command}`);
}
+1 -1
View File
@@ -38,7 +38,7 @@ function wrapForEvaluate(code: string): string {
// Security: Path validation to prevent path traversal attacks
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
function validateReadPath(filePath: string): void {
export 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 + '/'));
+6 -1
View File
@@ -249,12 +249,17 @@ async function handleCommand(body: any): Promise<Response> {
});
}
browserManager.resetFailures();
return new Response(result, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
});
} catch (err: any) {
return new Response(JSON.stringify({ error: wrapError(err) }), {
browserManager.incrementFailures();
let errorMsg = wrapError(err);
const hint = browserManager.getFailureHint();
if (hint) errorMsg += '\n' + hint;
return new Response(JSON.stringify({ error: errorMsg }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
+67
View File
@@ -0,0 +1,67 @@
/**
* URL validation for navigation commands — blocks dangerous schemes and cloud metadata endpoints.
* Localhost and private IPs are allowed (primary use case: QA testing local dev servers).
*/
const BLOCKED_METADATA_HOSTS = new Set([
'169.254.169.254', // AWS/GCP/Azure instance metadata
'fd00::', // IPv6 unique local (metadata in some cloud setups)
'metadata.google.internal', // GCP metadata
]);
/**
* Normalize hostname for blocklist comparison:
* - Strip trailing dot (DNS fully-qualified notation)
* - Strip IPv6 brackets (URL.hostname includes [] for IPv6)
* - Resolve hex (0xA9FEA9FE) and decimal (2852039166) IP representations
*/
function normalizeHostname(hostname: string): string {
// Strip IPv6 brackets
let h = hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1)
: hostname;
// Strip trailing dot
if (h.endsWith('.')) h = h.slice(0, -1);
return h;
}
/**
* Check if a hostname resolves to the link-local metadata IP 169.254.169.254.
* Catches hex (0xA9FEA9FE), decimal (2852039166), and octal (0251.0376.0251.0376) forms.
*/
function isMetadataIp(hostname: string): boolean {
// Try to parse as a numeric IP via URL constructor — it normalizes all forms
try {
const probe = new URL(`http://${hostname}`);
const normalized = probe.hostname;
if (BLOCKED_METADATA_HOSTS.has(normalized)) return true;
// Also check after stripping trailing dot
if (normalized.endsWith('.') && BLOCKED_METADATA_HOSTS.has(normalized.slice(0, -1))) return true;
} catch {
// Not a valid hostname — can't be a metadata IP
}
return false;
}
export function validateNavigationUrl(url: string): void {
let parsed: URL;
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.`
);
}
}
+2
View File
@@ -7,6 +7,7 @@
import type { BrowserManager } from './browser-manager';
import { findInstalledBrowsers, importCookies } from './cookie-import-browser';
import { validateNavigationUrl } from './url-validation';
import * as fs from 'fs';
import * as path from 'path';
@@ -21,6 +22,7 @@ export async function handleWriteCommand(
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})`;
+235
View File
@@ -0,0 +1,235 @@
/**
* Tests for handoff/resume commands — headless-to-headed browser switching.
*
* Unit tests cover saveState/restoreState, failure tracking, and edge cases.
* Integration tests cover the full handoff flow with real Playwright browsers.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { startTestServer } from './test-server';
import { BrowserManager, type BrowserState } from '../src/browser-manager';
import { handleWriteCommand } from '../src/write-commands';
import { handleMetaCommand } from '../src/meta-commands';
let testServer: ReturnType<typeof startTestServer>;
let bm: BrowserManager;
let baseUrl: string;
beforeAll(async () => {
testServer = startTestServer(0);
baseUrl = testServer.url;
bm = new BrowserManager();
await bm.launch();
});
afterAll(() => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
});
// ─── Unit Tests: Failure Tracking (no browser needed) ────────────
describe('failure tracking', () => {
test('getFailureHint returns null when below threshold', () => {
const tracker = new BrowserManager();
tracker.incrementFailures();
tracker.incrementFailures();
expect(tracker.getFailureHint()).toBeNull();
});
test('getFailureHint returns hint after 3 consecutive failures', () => {
const tracker = new BrowserManager();
tracker.incrementFailures();
tracker.incrementFailures();
tracker.incrementFailures();
const hint = tracker.getFailureHint();
expect(hint).not.toBeNull();
expect(hint).toContain('handoff');
expect(hint).toContain('3');
});
test('hint suppressed when already headed', () => {
const tracker = new BrowserManager();
(tracker as any).isHeaded = true;
tracker.incrementFailures();
tracker.incrementFailures();
tracker.incrementFailures();
expect(tracker.getFailureHint()).toBeNull();
});
test('resetFailures clears the counter', () => {
const tracker = new BrowserManager();
tracker.incrementFailures();
tracker.incrementFailures();
tracker.incrementFailures();
expect(tracker.getFailureHint()).not.toBeNull();
tracker.resetFailures();
expect(tracker.getFailureHint()).toBeNull();
});
test('getIsHeaded returns false by default', () => {
const tracker = new BrowserManager();
expect(tracker.getIsHeaded()).toBe(false);
});
});
// ─── Unit Tests: State Save/Restore (shared browser) ─────────────
describe('saveState', () => {
test('captures cookies and page URLs', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
await handleWriteCommand('cookie', ['testcookie=testvalue'], bm);
const state = await bm.saveState();
expect(state.cookies.length).toBeGreaterThan(0);
expect(state.cookies.some(c => c.name === 'testcookie')).toBe(true);
expect(state.pages.length).toBeGreaterThanOrEqual(1);
expect(state.pages.some(p => p.url.includes('/basic.html'))).toBe(true);
}, 15000);
test('captures localStorage and sessionStorage', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const page = bm.getPage();
await page.evaluate(() => {
localStorage.setItem('lsKey', 'lsValue');
sessionStorage.setItem('ssKey', 'ssValue');
});
const state = await bm.saveState();
const activePage = state.pages.find(p => p.isActive);
expect(activePage).toBeDefined();
expect(activePage!.storage).not.toBeNull();
expect(activePage!.storage!.localStorage).toHaveProperty('lsKey', 'lsValue');
expect(activePage!.storage!.sessionStorage).toHaveProperty('ssKey', 'ssValue');
}, 15000);
test('captures multiple tabs', async () => {
while (bm.getTabCount() > 1) {
await bm.closeTab();
}
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
await handleMetaCommand('newtab', [baseUrl + '/form.html'], bm, () => {});
const state = await bm.saveState();
expect(state.pages.length).toBe(2);
const activePage = state.pages.find(p => p.isActive);
expect(activePage).toBeDefined();
expect(activePage!.url).toContain('/form.html');
await bm.closeTab();
}, 15000);
});
describe('restoreState', () => {
test('state survives recreateContext round-trip', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
await handleWriteCommand('cookie', ['restored=yes'], bm);
const stateBefore = await bm.saveState();
expect(stateBefore.cookies.some(c => c.name === 'restored')).toBe(true);
await bm.recreateContext();
const stateAfter = await bm.saveState();
expect(stateAfter.cookies.some(c => c.name === 'restored')).toBe(true);
expect(stateAfter.pages.length).toBeGreaterThanOrEqual(1);
}, 30000);
});
// ─── Unit Tests: Handoff Edge Cases ──────────────────────────────
describe('handoff edge cases', () => {
test('handoff when already headed returns no-op', async () => {
(bm as any).isHeaded = true;
const result = await bm.handoff('test');
expect(result).toContain('Already in headed mode');
(bm as any).isHeaded = false;
}, 10000);
test('resume clears refs and resets failures', () => {
bm.incrementFailures();
bm.incrementFailures();
bm.incrementFailures();
bm.resume();
expect(bm.getFailureHint()).toBeNull();
expect(bm.getRefCount()).toBe(0);
});
test('resume without prior handoff works via meta command', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
const result = await handleMetaCommand('resume', [], bm, () => {});
expect(result).toContain('RESUMED');
}, 15000);
});
// ─── Integration Tests: Full Handoff Flow ────────────────────────
// Each handoff test creates its own BrowserManager since handoff swaps the browser.
// These tests run sequentially (one browser at a time) to avoid resource issues.
describe('handoff integration', () => {
test('full handoff: cookies preserved, headed mode active, commands work', async () => {
const hbm = new BrowserManager();
await hbm.launch();
try {
// Set up state
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
await handleWriteCommand('cookie', ['handoff_test=preserved'], hbm);
// Handoff
const result = await hbm.handoff('Testing handoff');
expect(result).toContain('HANDOFF:');
expect(result).toContain('Testing handoff');
expect(result).toContain('resume');
expect(hbm.getIsHeaded()).toBe(true);
// Verify cookies survived
const { handleReadCommand } = await import('../src/read-commands');
const cookiesResult = await handleReadCommand('cookies', [], hbm);
expect(cookiesResult).toContain('handoff_test');
// Verify commands still work
const text = await handleReadCommand('text', [], hbm);
expect(text.length).toBeGreaterThan(0);
// Resume
const resumeResult = await handleMetaCommand('resume', [], hbm, () => {});
expect(resumeResult).toContain('RESUMED');
} finally {
await hbm.close();
}
}, 45000);
test('multi-tab handoff preserves all tabs', async () => {
const hbm = new BrowserManager();
await hbm.launch();
try {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
await handleMetaCommand('newtab', [baseUrl + '/form.html'], hbm, () => {});
expect(hbm.getTabCount()).toBe(2);
await hbm.handoff('multi-tab test');
expect(hbm.getTabCount()).toBe(2);
expect(hbm.getIsHeaded()).toBe(true);
} finally {
await hbm.close();
}
}, 45000);
test('handoff meta command joins args as message', async () => {
const hbm = new BrowserManager();
await hbm.launch();
try {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], hbm);
const result = await handleMetaCommand('handoff', ['CAPTCHA', 'stuck'], hbm, () => {});
expect(result).toContain('CAPTCHA stuck');
} finally {
await hbm.close();
}
}, 45000);
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'bun:test';
import { validateOutputPath } from '../src/meta-commands';
import { validateReadPath } from '../src/read-commands';
describe('validateOutputPath', () => {
it('allows paths within /tmp', () => {
expect(() => validateOutputPath('/tmp/screenshot.png')).not.toThrow();
});
it('allows paths in subdirectories of /tmp', () => {
expect(() => validateOutputPath('/tmp/browse/output.png')).not.toThrow();
});
it('allows paths within cwd', () => {
expect(() => validateOutputPath(`${process.cwd()}/output.png`)).not.toThrow();
});
it('blocks paths outside safe directories', () => {
expect(() => validateOutputPath('/etc/cron.d/backdoor.png')).toThrow(/Path must be within/);
});
it('blocks /tmpevil prefix collision', () => {
expect(() => validateOutputPath('/tmpevil/file.png')).toThrow(/Path must be within/);
});
it('blocks home directory paths', () => {
expect(() => validateOutputPath('/Users/someone/file.png')).toThrow(/Path must be within/);
});
it('blocks path traversal via ..', () => {
expect(() => validateOutputPath('/tmp/../etc/passwd')).toThrow(/Path must be within/);
});
});
describe('validateReadPath', () => {
it('allows absolute paths within /tmp', () => {
expect(() => validateReadPath('/tmp/script.js')).not.toThrow();
});
it('allows absolute paths within cwd', () => {
expect(() => validateReadPath(`${process.cwd()}/test.js`)).not.toThrow();
});
it('allows relative paths without traversal', () => {
expect(() => validateReadPath('src/index.js')).not.toThrow();
});
it('blocks absolute paths outside safe directories', () => {
expect(() => validateReadPath('/etc/passwd')).toThrow(/Absolute path must be within/);
});
it('blocks /tmpevil prefix collision', () => {
expect(() => validateReadPath('/tmpevil/file.js')).toThrow(/Absolute path must be within/);
});
it('blocks path traversal sequences', () => {
expect(() => validateReadPath('../../../etc/passwd')).toThrow(/Path traversal/);
});
it('blocks nested path traversal', () => {
expect(() => validateReadPath('src/../../etc/passwd')).toThrow(/Path traversal/);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'bun:test';
import { validateNavigationUrl } from '../src/url-validation';
describe('validateNavigationUrl', () => {
it('allows http URLs', () => {
expect(() => validateNavigationUrl('http://example.com')).not.toThrow();
});
it('allows https URLs', () => {
expect(() => validateNavigationUrl('https://example.com/path?q=1')).not.toThrow();
});
it('allows localhost', () => {
expect(() => validateNavigationUrl('http://localhost:3000')).not.toThrow();
});
it('allows 127.0.0.1', () => {
expect(() => validateNavigationUrl('http://127.0.0.1:8080')).not.toThrow();
});
it('allows private IPs', () => {
expect(() => validateNavigationUrl('http://192.168.1.1')).not.toThrow();
});
it('blocks file:// scheme', () => {
expect(() => validateNavigationUrl('file:///etc/passwd')).toThrow(/scheme.*not allowed/i);
});
it('blocks javascript: scheme', () => {
expect(() => validateNavigationUrl('javascript:alert(1)')).toThrow(/scheme.*not allowed/i);
});
it('blocks data: scheme', () => {
expect(() => validateNavigationUrl('data:text/html,<h1>hi</h1>')).toThrow(/scheme.*not allowed/i);
});
it('blocks AWS/GCP metadata endpoint', () => {
expect(() => validateNavigationUrl('http://169.254.169.254/latest/meta-data/')).toThrow(/cloud metadata/i);
});
it('blocks GCP metadata hostname', () => {
expect(() => validateNavigationUrl('http://metadata.google.internal/computeMetadata/v1/')).toThrow(/cloud metadata/i);
});
it('blocks metadata hostname with trailing dot', () => {
expect(() => validateNavigationUrl('http://metadata.google.internal./computeMetadata/v1/')).toThrow(/cloud metadata/i);
});
it('blocks metadata IP in hex form', () => {
expect(() => validateNavigationUrl('http://0xA9FEA9FE/')).toThrow(/cloud metadata/i);
});
it('blocks metadata IP in decimal form', () => {
expect(() => validateNavigationUrl('http://2852039166/')).toThrow(/cloud metadata/i);
});
it('blocks metadata IP in octal form', () => {
expect(() => validateNavigationUrl('http://0251.0376.0251.0376/')).toThrow(/cloud metadata/i);
});
it('blocks IPv6 metadata with brackets', () => {
expect(() => validateNavigationUrl('http://[fd00::]/')).toThrow(/cloud metadata/i);
});
it('throws on malformed URLs', () => {
expect(() => validateNavigationUrl('not-a-url')).toThrow(/Invalid URL/i);
});
});