feat: browser data platform for AI agents (v0.16.0.0) (#907)

* refactor: extract path-security.ts shared module

validateOutputPath, validateReadPath, and SAFE_DIRECTORIES were duplicated
across write-commands.ts, meta-commands.ts, and read-commands.ts. Extract
to a single shared module with re-exports for backward compatibility.

Also adds validateTempPath() for the upcoming GET /file endpoint (TEMP_DIR
only, not cwd, to prevent remote agents from reading project files).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: default paired agents to full access, split SCOPE_CONTROL

The trust boundary for paired agents is the pairing ceremony itself, not
the scope. An agent with write scope can already click anything and navigate
anywhere. Gating js/cookies behind --admin was security theater.

Changes:
- Default pair scopes: read+write+admin+meta (was read+write)
- New SCOPE_CONTROL for browser-wide destructive ops (stop, restart,
  disconnect, state, handoff, resume, connect)
- --admin flag now grants control scope (backward compat)
- New --restrict flag for limited access (e.g., --restrict read)
- Updated hint text: "re-pair with --control" instead of "--admin"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add media and data commands for page content extraction

media command: discovers all img/video/audio/background-image elements
on the page. Returns JSON with URLs, dimensions, srcset, loading state,
HLS/DASH detection. Supports --images/--videos/--audio filters and
optional CSS selector scoping.

data command: extracts structured data embedded in pages (JSON-LD,
Open Graph, Twitter Cards, meta tags). One command returns product
prices, article metadata, social share info without DOM scraping.

Both are READ scope with untrusted content wrapping.
Shared media-extract.ts helper for reuse by the upcoming scrape command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add download, scrape, and archive commands

download: fetch any URL or @ref element to disk using browser session
cookies via page.request.fetch(). Supports blob: URLs via in-page
base64 conversion. --base64 flag returns inline data URI (cap 10MB).
Detects HLS/DASH and rejects with yt-dlp hint.

scrape: bulk media download composing media discovery + download loop.
Sequential with 100ms delay, URL deduplication, configurable --limit.
Writes manifest.json with per-file metadata for machine consumption.

archive: saves complete page as MHTML via CDP Page.captureSnapshot.
No silent fallback -- errors clearly if CDP unavailable.

All three are WRITE scope (write to disk, blocked in watch mode).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add GET /file endpoint for remote agent file retrieval

Remote paired agents can now retrieve downloaded files over HTTP.
TEMP_DIR only (not cwd) to prevent project file exfiltration.

- Bearer token auth (root or scoped with read scope)
- Path validation via validateTempPath() (symlink-aware)
- 200MB size cap
- Extension-based MIME detection
- Zero-copy streaming via Bun.file()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add scroll --times N for automated repeated scrolling

Extends the scroll command with --times N flag for infinite feed
scraping. Scrolls N times with configurable --wait delay (default
1000ms) between each scroll for content loading.

Usage: scroll --times 10
       scroll --times 5 --wait 2000
       scroll --times 3 .feed-container

Composable with scrape: scroll to load content, then scrape images.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add network response body capture (--capture/--export/--bodies)

The killer feature for social media scraping. Extends the existing
network command to intercept API response bodies:

  network --capture [--filter graphql]  # start capturing
  network --capture stop                # stop
  network --export /tmp/api.jsonl       # export as JSONL
  network --bodies                      # show summary

Uses page.on('response') listener with URL pattern filtering.
SizeCappedBuffer (50MB total, 5MB per-entry cap) evicts oldest
entries when full. Binary responses stored as base64, text as-is.

This lets agents tap Instagram's GraphQL API, TikTok's hydration
data, and any SPA's internal API responses instead of fragile DOM
scraping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add screenshot --base64 for inline image return

Returns data:image/png;base64,... instead of writing to disk.
Cap at 10MB. Works with all screenshot modes (element, clip, viewport).

Eliminates the two-step screenshot+file-serve dance for remote agents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add data platform tests and media fixture

Tests for SizeCappedBuffer (eviction, export, summary), validateTempPath
(TEMP_DIR only, rejects cwd), command registration (all new commands in
correct scope sets), and MIME mapping source checks.

Rich HTML fixture with: standard images, lazy-loaded images, srcset,
video with sources + HLS, audio, CSS background-images, JSON-LD,
Open Graph, Twitter Cards, and meta tags.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: regenerate SKILL.md with Extraction category

Add Extraction category to browse command table ordering. Regenerate
SKILL.md files to include media, data, download, scrape, archive
commands in the generated documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-08 00:41:55 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 9d34baa973
commit b73f364411
20 changed files with 1264 additions and 166 deletions
+118 -33
View File
@@ -10,8 +10,11 @@ import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
import type { Page, Frame } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import { TEMP_DIR, isPathWithin } from './platform';
import { TEMP_DIR } from './platform';
import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector';
import { validateReadPath } from './path-security';
// Re-export for backward compatibility (tests import from read-commands)
export { validateReadPath } from './path-security';
// Redaction patterns for sensitive cookie/storage values — exported for test coverage
export const SENSITIVE_COOKIE_NAME = /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf|sid)($|[_.-])|api.?key/i;
@@ -41,38 +44,6 @@ function wrapForEvaluate(code: string): string {
: `(async()=>(${trimmed}))()`;
}
// Security: Path validation to prevent path traversal attacks
// Resolve safe directories through realpathSync to handle symlinks (e.g., macOS /tmp → /private/tmp)
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()].map(d => {
try { return fs.realpathSync(d); } catch { return d; }
});
export function validateReadPath(filePath: string): void {
// Always resolve to absolute first (fixes relative path symlink bypass)
const resolved = path.resolve(filePath);
// Resolve symlinks — throw on non-ENOENT errors
let realPath: string;
try {
realPath = fs.realpathSync(resolved);
} catch (err: any) {
if (err.code === 'ENOENT') {
// File doesn't exist — resolve directory part for symlinks (e.g., /tmp → /private/tmp)
try {
const dir = fs.realpathSync(path.dirname(resolved));
realPath = path.join(dir, path.basename(resolved));
} catch {
realPath = resolved;
}
} else {
throw new Error(`Cannot resolve real path: ${filePath} (${err.code})`);
}
}
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realPath, dir));
if (!isSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
}
/**
* Extract clean text from a page (strips script/style/noscript/svg).
* Exported for DRY reuse in meta-commands (diff).
@@ -254,6 +225,50 @@ export async function handleReadCommand(
networkBuffer.clear();
return 'Network buffer cleared.';
}
// Network capture extensions
if (args[0] === '--capture') {
const {
startCapture, stopCapture, getCaptureListener, isCaptureActive,
} = await import('./network-capture');
if (args[1] === 'stop') {
// Detach listener from current page
const page = bm.getPage();
const listener = getCaptureListener();
if (listener) page.removeListener('response', listener);
const result = stopCapture();
return `Network capture stopped. ${result.count} responses captured (${result.sizeKB}KB).`;
}
// Start capture
if (isCaptureActive()) return 'Capture already active. Use --capture stop first.';
const filterIdx = args.indexOf('--filter');
const filterPattern = filterIdx >= 0 ? args[filterIdx + 1] : undefined;
const info = startCapture(filterPattern);
// Attach listener to current page
const page = bm.getPage();
const listener = getCaptureListener();
if (listener) page.on('response', listener);
return `Network capture started${info.filter ? ` (filter: ${info.filter})` : ''}. Use --capture stop to stop.`;
}
if (args[0] === '--export') {
const { exportCapture } = await import('./network-capture');
const { validateOutputPath: vop } = await import('./path-security');
const exportPath = args[1];
if (!exportPath) throw new Error('Usage: network --export <path>');
vop(exportPath);
const count = exportCapture(exportPath);
return `Exported ${count} captured responses to ${exportPath}`;
}
if (args[0] === '--bodies') {
const { getCaptureBuffer } = await import('./network-capture');
return getCaptureBuffer().summary();
}
// Default: show request metadata
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)`
@@ -412,6 +427,76 @@ export async function handleReadCommand(
return formatInspectorResult(result, { includeUA });
}
case 'media': {
const { extractMedia } = await import('./media-extract');
const target = bm.getActiveFrameOrPage();
const filter = args.includes('--images') ? 'images' as const
: args.includes('--videos') ? 'videos' as const
: args.includes('--audio') ? 'audio' as const
: undefined;
const selectorArg = args.find(a => !a.startsWith('--'));
const result = await extractMedia(target, { selector: selectorArg, filter });
return JSON.stringify(result, null, 2);
}
case 'data': {
const target = bm.getActiveFrameOrPage();
const wantJsonLd = args.includes('--jsonld') || args.length === 0;
const wantOg = args.includes('--og') || args.length === 0;
const wantTwitter = args.includes('--twitter') || args.length === 0;
const wantMeta = args.includes('--meta') || args.length === 0;
const result = await target.evaluate(({ wantJsonLd, wantOg, wantTwitter, wantMeta }) => {
const data: Record<string, any> = {};
if (wantJsonLd) {
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
const jsonLd: any[] = [];
scripts.forEach(s => {
try { jsonLd.push(JSON.parse(s.textContent || '')); } catch {}
});
data.jsonLd = jsonLd;
}
if (wantOg) {
const og: Record<string, string> = {};
document.querySelectorAll('meta[property^="og:"]').forEach(m => {
const prop = m.getAttribute('property')?.replace('og:', '') || '';
og[prop] = m.getAttribute('content') || '';
});
data.openGraph = og;
}
if (wantTwitter) {
const tw: Record<string, string> = {};
document.querySelectorAll('meta[name^="twitter:"]').forEach(m => {
const name = m.getAttribute('name')?.replace('twitter:', '') || '';
tw[name] = m.getAttribute('content') || '';
});
data.twitterCards = tw;
}
if (wantMeta) {
const meta: Record<string, string> = {};
const canonical = document.querySelector('link[rel="canonical"]');
if (canonical) meta.canonical = canonical.getAttribute('href') || '';
const desc = document.querySelector('meta[name="description"]');
if (desc) meta.description = desc.getAttribute('content') || '';
const keywords = document.querySelector('meta[name="keywords"]');
if (keywords) meta.keywords = keywords.getAttribute('content') || '';
const author = document.querySelector('meta[name="author"]');
if (author) meta.author = author.getAttribute('content') || '';
const title = document.querySelector('title');
if (title) meta.title = title.textContent || '';
data.meta = meta;
}
return data;
}, { wantJsonLd, wantOg, wantTwitter, wantMeta });
return JSON.stringify(result, null, 2);
}
default:
throw new Error(`Unknown read command: ${command}`);
}