mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 13:45:35 +02:00
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:
+59
-6
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 + '/'));
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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})`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user