Compare commits

...

10 Commits

Author SHA1 Message Date
marauder-actual 5e1375f1a1 v0.4.1: Chrome cookie import, config.json, session persistence
CI / test (push) Has been cancelled
style / style (push) Has been cancelled
- Add Chrome cookie importer (macOS Keychain AES-128-CBC decryption)
- Add ~/.config/browse/config.json (headless, fullscreen, stealth, preview defaults)
- Add ~/.config/browse/session.json (auto-save/restore cookies + localStorage)
- Auto-import from all browsers (Safari + Firefox + Chrome) on first launch
- Deduplicate cookies across browsers (domain+name+path key)
- Defaults: headed, fullscreen, stealth, preview all enabled
- Fix BigInt overflow for Chrome timestamps (CAST to TEXT in SQL)
2026-06-06 16:10:50 +02:00
marauder-actual 16bc55bcb9 v0.4.1 2026-06-06 15:55:15 +02:00
aladac 1426ec9173 Add HTTP server mode with native node:http
Reintroduce server mode (removed in 3014cf9) using node:http instead of
Express — zero new dependencies. Accepts JSON commands via POST to /,
returns JSON responses with CORS support. Screenshot command returns
base64 data when no path is specified.

- Add src/server.ts with BrowserServer class using node:http
- Add -s/--server <port> CLI flag (default 13373)
- Export BrowserServer and startServer from index.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 16:23:16 +02:00
aladac a80224df36 feat: add preview tool — navigate + screenshot in one call with optional POST
New MCP tool `preview` combines goto + screenshot with viewport control.
Optionally POSTs result to any HTTP endpoint (e.g. HUD/visor) via previewUrl.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:44:52 +02:00
aladac 7171fbfc76 Bump version to 0.3.3 2026-04-13 13:26:54 +02:00
aladac c3eb4a0a92 Fix networkidle timeouts and Firefox cookie domain normalization
- Replace networkidle with domcontentloaded + 5s race in goto, click,
  and session_restore — SPAs with persistent connections (LinkedIn,
  Gmail) never reach networkidle
- Normalize .www. cookie domains in Firefox importer — Playwright
  silently drops cookies with .www.example.com domains

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:26:53 +02:00
aladac 5abc924ee7 Bump version to 0.3.2 2026-04-13 13:14:11 +02:00
aladac 00682dc9f6 Fix repository URL format in package.json
npm pkg fix — normalize repository.url to git+https format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:11:35 +02:00
aladac f5e99aafc8 Bump version to 0.3.1 2026-04-13 13:09:33 +02:00
aladac aac616a37c Fix Firefox cookie expiry conversion for Playwright
Some Firefox cookies store expiry in milliseconds instead of seconds.
Detect values beyond year 2100 and convert to seconds. Also map
zero/negative expiry to -1 for Playwright session cookies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:09:15 +02:00
43 changed files with 1955 additions and 340 deletions
+60 -229
View File
@@ -1,249 +1,80 @@
# Plan: Playwright Debugging Features # Plan: Preview Tool — Single-Call Screenshot-to-Visor Pipeline
## Phase 1: Core Debugging (Console & Errors) ## Context
### Description Currently, previewing an HTML mockup or live URL on the MARAUDER VISOR requires 4 sequential tool calls: `launch``goto``screenshot``bash curl POST /image`. This is slow, verbose, and prone to Claude pausing between steps to narrate.
Implement fundamental debugging capabilities: console message capture and uncaught exception handling. These form the foundation for debugging client-side issues.
### Steps **Goal:** Add a `preview` tool to the browse MCP that does the entire pipeline in one call. Optionally pushes to the visor automatically.
#### Step 1.1: Add Console Command ## Design
- **Objective**: Capture and retrieve console messages from browser
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: None
- **Status**: COMPLETE
- **Implementation**:
- Add `ConsoleCommand` type with `level` filter and `clear` option
- Store messages via `page.on('console')` listener
- Return messages with level, text, timestamp, location
#### Step 1.2: Add Page Errors Command ### New Tool: `preview`
- **Objective**: Capture uncaught exceptions and unhandled promise rejections
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: Step 1.1
- **Implementation**:
- Add `ErrorsCommand` type with `clear` option
- Listen to `page.on('pageerror')` for uncaught exceptions
- Store error message, stack trace, timestamp
- Add `browser://errors` MCP resource
## Phase 2: Network Monitoring browse is a standalone npm package — it must NOT know about the visor. The `preview` tool is a convenience wrapper for "goto + screenshot with viewport control" in a single call.
### Description ```typescript
Implement network request/response capture for debugging API calls, identifying failed requests, and inspecting payloads. server.tool('preview', 'Navigate to URL and screenshot in one call with custom viewport', {
url: z.string(), // URL or file:///path
width: z.number().optional().default(1280), // Viewport width
height: z.number().optional().default(800), // Viewport height
fullPage: z.boolean().optional().default(false), // Full page capture
output: z.string().optional().default('/tmp/preview.png'), // Screenshot path
});
```
### Steps **Returns:**
```json
{
"ok": true,
"path": "/tmp/preview.png",
"url": "https://kwit.fit",
"title": "kwit*fit"
}
```
#### Step 2.1: Add Network Logging ### Behavior
- **Objective**: Capture all network requests and responses
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: None
- **Implementation**:
- Add `NetworkCommand` type with `filter` and `clear` options
- Listen to `page.on('request')` and `page.on('response')`
- Store: url, method, status, resourceType, timing, headers
- Add optional body capture for XHR/fetch (with size limit)
- Add `browser://network` MCP resource
#### Step 2.2: Add Failed Requests Filter 1. If browser not launched → launch headless with given viewport dimensions
- **Objective**: Quick access to failed/error requests 2. If browser already running with different viewport → resize to requested dimensions
- **Files**: `src/browser.ts`, `src/mcp.ts` 3. Navigate to URL (supports `https://`, `http://`, `file:///`)
- **Dependencies**: Step 2.1 4. Wait for `networkidle` (with 5s timeout for SPAs)
- **Implementation**: 5. Take screenshot → save to `output` path
- Add `failed` filter option to NetworkCommand 6. Return result with path, url, and page title
- Include requests with status >= 400 or network errors
- Add `browser://network/failed` MCP resource
#### Step 2.3: Add Request Interception ### Visor Integration (marauder-plugin side, NOT in browse)
- **Objective**: Block or mock specific requests
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: Step 2.1
- **Implementation**:
- Add `InterceptCommand` with `action: 'block' | 'mock' | 'clear'`
- Support URL patterns (glob or regex)
- For mock: allow custom response body/status
- Use `page.route()` for interception
## Phase 3: Performance & Metrics The visor push lives in the marauder-plugin preview skill as a simple bash curl after the browse tool returns. This keeps browse generic and visor-agnostic.
### Description ## Files to Modify
Add performance timing and metrics collection for identifying bottlenecks and measuring page load characteristics.
### Steps | File | Change |
|------|--------|
| `src/types.ts` | Add `PreviewCommand` interface + add to `BrowserCommand` union |
| `src/browser.ts` | Add `preview()` method + `pushToVisor()` helper + case in `executeCommand()` |
| `src/mcp.ts` | Register `preview` tool with zod schema |
| `src/index.ts` | No change needed (types auto-exported) |
#### Step 3.1: Add Performance Metrics ## Files Unchanged
- **Objective**: Return page performance timing data
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: None
- **Implementation**:
- Add `MetricsCommand` type
- Collect via `performance.timing` and `performance.getEntriesByType()`
- Return: domContentLoaded, load, firstPaint, firstContentfulPaint
- Include DOM stats: nodeCount, scriptCount, styleCount
#### Step 3.2: Add Resource Timing - `src/cli.ts` — CLI doesn't need preview (it's an MCP-first tool)
- **Objective**: Get timing breakdown for individual resources - `src/image.ts` — No image processing needed
- **Files**: `src/browser.ts`, `src/mcp.ts` - `src/safari.ts`, `src/firefox.ts` — Unrelated
- **Dependencies**: Step 3.1
- **Implementation**:
- Add `resources` option to MetricsCommand
- Use `performance.getEntriesByType('resource')`
- Return: name, duration, transferSize, initiatorType
## Phase 4: Accessibility ## Risks
### Description | Risk | Mitigation |
Implement accessibility tree inspection for debugging screen reader and a11y issues. |------|------------|
| Browser already launched with wrong viewport | Resize viewport before navigating |
| `file:///` URLs blocked by Playwright | WebKit allows file:// by default |
| Visor not running | Silent fail, return `visor: false` in response |
| Node `fetch` not available | Node 18+ has native fetch; browse requires Node 18+ |
### Steps ## Verification
#### Step 4.1: Add Accessibility Snapshot 1. `npm run build` — compiles
- **Objective**: Dump accessibility tree for page or element 2. `npm run check` — passes lint/format
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts` 3. Manual test: call `preview` tool from Claude Code with a URL
- **Dependencies**: None 4. Manual test: call `preview` with `file:///tmp/mockup.html`
- **Implementation**: 5. Verify visor displays the screenshot
- Add `A11yCommand` with optional `selector` for subtree 6. `npm run test` if tests exist
- Use `page.accessibility.snapshot()` 7. Bump version, publish to npm
- Return tree with role, name, value, description
- Add `browser://a11y` MCP resource
## Phase 5: Dialog Handling
### Description
Implement automatic handling of browser dialogs (alert, confirm, prompt) to prevent blocking during automation.
### Steps
#### Step 5.1: Add Dialog Command
- **Objective**: Configure how dialogs are handled
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: None
- **Implementation**:
- Add `DialogCommand` with `action: 'accept' | 'dismiss' | 'status'`
- Option to set default behavior for future dialogs
- Option to provide text response for prompts
- Store dialog history (type, message, response)
- Listen to `page.on('dialog')`
## Phase 6: Storage & Cookies
### Description
Add commands for inspecting and manipulating browser storage, complementing the existing session save/restore.
### Steps
#### Step 6.1: Add Cookies Command
- **Objective**: Get, set, and clear cookies
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: None
- **Implementation**:
- Add `CookiesCommand` with `action: 'get' | 'set' | 'delete' | 'clear'`
- Use `context.cookies()` and `context.addCookies()`
- Support filtering by name/domain
- Add `browser://cookies` MCP resource
#### Step 6.2: Add Storage Command
- **Objective**: Inspect/modify localStorage and sessionStorage
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: None
- **Implementation**:
- Add `StorageCommand` with `type: 'local' | 'session'`
- Actions: get, set, delete, clear
- Implement via `page.evaluate()`
- Add `browser://storage/local` and `browser://storage/session` resources
## Phase 7: Advanced Interactions
### Description
Add additional interaction commands for comprehensive testing scenarios.
### Steps
#### Step 7.1: Add Hover Command
- **Objective**: Trigger hover state on elements
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: None
- **Implementation**:
- Add `HoverCommand` with `selector`
- Use `page.hover(selector)`
#### Step 7.2: Add Select Command
- **Objective**: Select options in dropdown elements
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: None
- **Implementation**:
- Add `SelectCommand` with `selector` and `value` (or values array)
- Use `page.selectOption()`
#### Step 7.3: Add Keys Command
- **Objective**: Send keyboard shortcuts and special keys
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: None
- **Implementation**:
- Add `KeysCommand` with `keys` string (e.g., "Control+a", "Escape")
- Use `page.keyboard.press()`
#### Step 7.4: Add Upload Command
- **Objective**: Set files on file input elements
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: None
- **Implementation**:
- Add `UploadCommand` with `selector` and `files` array
- Use `page.setInputFiles()`
#### Step 7.5: Add Scroll Command
- **Objective**: Scroll page or element into view
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: None
- **Implementation**:
- Add `ScrollCommand` with optional `selector`, `x`, `y`
- If selector: use `element.scrollIntoView()`
- Otherwise: use `window.scrollTo()`
## Phase 8: Viewport & Emulation
### Description
Add device emulation and viewport manipulation for responsive testing.
### Steps
#### Step 8.1: Add Viewport Command
- **Objective**: Resize browser viewport dynamically
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: None
- **Implementation**:
- Add `ViewportCommand` with `width` and `height`
- Use `page.setViewportSize()`
#### Step 8.2: Add Emulate Command
- **Objective**: Emulate specific devices
- **Files**: `src/types.ts`, `src/browser.ts`, `src/mcp.ts`
- **Dependencies**: Step 8.1
- **Implementation**:
- Add `EmulateCommand` with `device` name
- Use Playwright's device descriptors
- Apply viewport, userAgent, deviceScaleFactor, touch support
## Phase 9: Documentation
### Description
Update all documentation with new commands and examples.
### Steps
#### Step 9.1: Update README
- **Objective**: Document all commands with examples
- **Files**: `README.md`
- **Dependencies**: Phases 1-8
- **Implementation**:
- Add command reference grouped by category
- Include curl/MCP examples
- Document response formats
#### Step 9.2: Update CLAUDE.md
- **Objective**: Update developer documentation
- **Files**: `CLAUDE.md`
- **Dependencies**: Step 9.1
- **Implementation**:
- Update architecture notes
- Document new command types
+36
View File
@@ -2,6 +2,42 @@
Research notes on making Playwright WebKit less detectable by anti-bot systems. Compiled April 2026. Research notes on making Playwright WebKit less detectable by anti-bot systems. Compiled April 2026.
---
## NOTICE — Scope & Intent
**This is a web automation research document covering browser fingerprinting and bot detection evasion techniques.** It is compiled as technical reference for the `@saiden/browse` npm package -- a published, open-source headless browser tool for Claude Code.
### Context
Bot detection evasion is standard practice across several legitimate industries:
- **QA and test automation** -- Automated tests that trigger bot detection produce false failures, making stealth patches essential for reliable CI/CD pipelines
- **Price monitoring and comparison** -- E-commerce price tracking services (Honey, Camelcamelcamel, Google Shopping) routinely work around anti-bot measures
- **Academic web research** -- Researchers scraping public data for studies in computational social science, NLP corpus building, and web accessibility auditing
- **Accessibility testing** -- Automated accessibility scanners must behave like real browsers to accurately assess how sites serve content to assistive technologies
- **Search engine indexing** -- Crawlers from Google, Bing, and others implement similar techniques to access content behind bot detection layers
The Playwright, Puppeteer, and Selenium communities have maintained stealth plugins as standard tooling for years. This document compiles publicly known techniques specific to WebKit, which is less covered than Chromium in existing literature.
### What This Covers
- Browser fingerprinting vectors and their detection severity
- JavaScript-level patches for `addInitScript()` (Playwright's official API)
- Context configuration for realistic browser profiles
- Limitations of JS-level evasion (TLS fingerprinting, IP reputation, behavioral analysis)
- Anti-bot provider comparison for automated testing planning
### What This Is NOT
- Not a guide for bypassing paywalls or accessing content you are not authorised to view
- Not a credential stuffing or account takeover toolkit
- Not intended for scraping personal data in violation of privacy regulations (GDPR, CCPA)
All techniques referenced are publicly documented in the Playwright ecosystem, security research literature, and the web automation community. Sources are cited at the end of this document.
---
## Current State ## Current State
Browse uses **Playwright WebKit** with a bare context — no stealth patches. This is trivially detected by every major anti-bot system (Cloudflare, DataDome, PerimeterX/HUMAN, Akamai). Browse uses **Playwright WebKit** with a bare context — no stealth patches. This is trivially detected by every major anti-bot system (Cloudflare, DataDome, PerimeterX/HUMAN, Akamai).
+28 -34
View File
@@ -1,39 +1,33 @@
# TODO: Playwright Debugging Features # TODO: Preview Tool
## Phase 1: Core Debugging (Console & Errors) ## Phase 1: Add `preview` tool
- [x] Step 1.1: Add Console Command - [ ] Add `PreviewCommand` interface to `src/types.ts`
- [x] Step 1.2: Add Page Errors Command - [ ] Add to `BrowserCommand` discriminated union
- [ ] Add `pushToVisor()` helper method to `ClaudeBrowser` in `src/browser.ts`
- [ ] Add `preview()` method to `ClaudeBrowser`
- [ ] Add `case 'preview'` in `executeCommand()` switch
- [ ] Register `preview` MCP tool in `src/mcp.ts` with zod schema
- [ ] `npm run build` — compiles clean
- [ ] `npm run check` — lint/format pass
## Phase 2: Network Monitoring ## Phase 2: Test & Publish
- [x] Step 2.1: Add Network Logging - [ ] Test with URL: `preview({ url: "https://kwit.fit", title: "TEST" })`
- [x] Step 2.2: Add Failed Requests Filter - [ ] Test with file: `preview({ url: "file:///tmp/test.html" })`
- [x] Step 2.3: Add Request Interception - [ ] Test visor push works
- [ ] Test visor-down graceful fallback (`visor: false` in response)
- [ ] Test viewport resize when browser already running
- [ ] Bump version, publish to npm
- [ ] Update marauder-plugin `.mcp.json` if needed
## Phase 3: Performance & Metrics ## Phase 3: Skill cleanup
- [x] Step 3.1: Add Performance Metrics - [ ] Delete `marauder-plugin/skills/preview/preview.py`
- [x] Step 3.2: Add Resource Timing - [ ] Rewrite `marauder-plugin/skills/preview/SKILL.md` as simple one-liner reference
## Phase 4: Accessibility ### ETA
- [x] Step 4.1: Add Accessibility Snapshot
## Phase 5: Dialog Handling | Phase | Naive | Coop | Sessions | Notes |
- [x] Step 5.1: Add Dialog Command |-------|-------|------|----------|-------|
| 1. Add tool | 2h | ~30m | 1 | Mechanical — follow existing pattern exactly |
## Phase 6: Storage & Cookies | 2. Test & publish | 1h | ~15m | 1 | Same session |
- [x] Step 6.1: Add Cookies Command | 3. Skill cleanup | 30m | ~10m | 1 | Delete + rewrite |
- [x] Step 6.2: Add Storage Command | **Total** | **3.5h** | **~55m** | **1** | Single session, single commit |
## Phase 7: Advanced Interactions
- [x] Step 7.1: Add Hover Command
- [x] Step 7.2: Add Select Command
- [x] Step 7.3: Add Keys Command
- [x] Step 7.4: Add Upload Command
- [x] Step 7.5: Add Scroll Command
## Phase 8: Viewport & Emulation
- [x] Step 8.1: Add Viewport Command
- [x] Step 8.2: Add Emulate Command
## Phase 9: Documentation
- [x] Step 9.1: Update README
- [x] Step 9.2: Update CLAUDE.md
+7
View File
@@ -118,6 +118,13 @@ export declare class ClaudeBrowser {
private handleDialogCommand; private handleDialogCommand;
private handleCookiesCommand; private handleCookiesCommand;
private handleStorageCommand; private handleStorageCommand;
private handlePreviewCommand;
/**
* POST a screenshot to a preview endpoint.
* Payload: { source: "file:///path", title, caption }
* Silent failure — returns false if endpoint is unreachable.
*/
private postPreview;
private handleImportCommand; private handleImportCommand;
executeCommand(cmd: BrowserCommand): Promise<CommandResponse>; executeCommand(cmd: BrowserCommand): Promise<CommandResponse>;
} }
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"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"} {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"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"}
+76 -2
View File
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { webkit } from 'playwright'; import { webkit } from 'playwright';
const execAsync = promisify(exec); const execAsync = promisify(exec);
import * as chrome from './chrome.js';
import * as firefox from './firefox.js'; import * as firefox from './firefox.js';
import * as image from './image.js'; import * as image from './image.js';
import * as safari from './safari.js'; import * as safari from './safari.js';
@@ -337,14 +338,17 @@ export class ClaudeBrowser {
} }
async goto(url) { async goto(url) {
const page = this.ensurePage(); const page = this.ensurePage();
await page.goto(url, { waitUntil: 'networkidle' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
// Best-effort wait for network to settle — SPAs with persistent connections
// (LinkedIn, Twitter, Gmail) never reach networkidle, so cap at 5s
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(() => { });
return { url: page.url(), title: await page.title() }; return { url: page.url(), title: await page.title() };
} }
async click(selector) { async click(selector) {
const page = this.ensurePage(); const page = this.ensurePage();
await this.previewAction(selector, 'CLICK'); await this.previewAction(selector, 'CLICK');
await page.click(selector); await page.click(selector);
await page.waitForLoadState('networkidle').catch(() => { }); await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(() => { });
return { url: page.url() }; return { url: page.url() };
} }
async type(selector, text) { async type(selector, text) {
@@ -766,6 +770,51 @@ export class ClaudeBrowser {
return { ok: false, error: 'Unknown storage action' }; return { ok: false, error: 'Unknown storage action' };
} }
} }
async handlePreviewCommand(cmd) {
const page = this.ensurePage();
// Resize viewport if dimensions specified
if (cmd.width || cmd.height) {
const current = page.viewportSize();
const width = cmd.width || current?.width || 1280;
const height = cmd.height || current?.height || 800;
await page.setViewportSize({ width, height });
}
// Navigate
const nav = await this.goto(cmd.url);
// Screenshot
const outputPath = resolve(cmd.output || '/tmp/preview.png');
await page.screenshot({ path: outputPath, fullPage: cmd.fullPage || false });
// Optional: POST to preview endpoint
let posted = false;
if (cmd.previewUrl) {
posted = await this.postPreview(outputPath, cmd.previewUrl, cmd.title, cmd.caption);
}
return { ok: true, path: outputPath, url: nav.url, title: nav.title, posted };
}
/**
* POST a screenshot to a preview endpoint.
* Payload: { source: "file:///path", title, caption }
* Silent failure — returns false if endpoint is unreachable.
*/
async postPreview(imagePath, previewUrl, title, caption) {
try {
const payload = JSON.stringify({
source: `file://${resolve(imagePath)}`,
title: title || null,
caption: caption || null,
});
const res = await fetch(previewUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
signal: AbortSignal.timeout(3000),
});
return res.ok;
}
catch {
return false;
}
}
async handleImportCommand(cmd) { async handleImportCommand(cmd) {
const context = this.getContext(); const context = this.getContext();
if (!context) if (!context)
@@ -818,6 +867,29 @@ export class ClaudeBrowser {
domains, domains,
}; };
} }
if (cmd.source === 'chrome') {
const cookies = chrome.importChromeCookies({
domain: cmd.domain,
profile: cmd.profile,
});
if (cookies.length === 0) {
return {
ok: true,
imported: 0,
source: 'chrome',
domains: [],
};
}
const playwrightCookies = cookies.map(chrome.toPlaywrightCookie);
await context.addCookies(playwrightCookies);
const domains = [...new Set(cookies.map((c) => c.domain))];
return {
ok: true,
imported: cookies.length,
source: 'chrome',
domains,
};
}
return { ok: false, error: `Unknown import source: ${cmd.source}` }; return { ok: false, error: `Unknown import source: ${cmd.source}` };
} }
async executeCommand(cmd) { async executeCommand(cmd) {
@@ -1009,6 +1081,8 @@ export class ClaudeBrowser {
} }
case 'import': case 'import':
return this.handleImportCommand(cmd); return this.handleImportCommand(cmd);
case 'preview':
return this.handlePreviewCommand(cmd);
default: { default: {
const _exhaustive = cmd; const _exhaustive = cmd;
return { ok: false, error: `Unknown command: ${_exhaustive.cmd}` }; return { ok: false, error: `Unknown command: ${_exhaustive.cmd}` };
+1 -1
View File
File diff suppressed because one or more lines are too long
+52
View File
@@ -0,0 +1,52 @@
/**
* Chrome cookie importer (macOS)
*
* Reads cookies from Chrome's SQLite database and decrypts values using
* the encryption key stored in the macOS Keychain ("Chrome Safe Storage").
*
* Encryption scheme (macOS):
* - Keychain password → PBKDF2(password, salt="saltysalt", iterations=1003, keylen=16)
* - AES-128-CBC with IV = 16 bytes of 0x20 (space)
* - Encrypted values prefixed with "v10" (3 bytes)
*
* Chrome timestamps are microseconds since Jan 1, 1601 (Windows/WebKit epoch).
* Database is copied to a temp directory to avoid WAL lock conflicts.
*/
export interface ChromeCookie {
name: string;
value: string;
domain: string;
path: string;
expires: number;
secure: boolean;
httpOnly: boolean;
sameSite: 'None' | 'Lax' | 'Strict';
}
/**
* List available Chrome profiles
*/
export declare function listChromeProfiles(): {
name: string;
path: string;
}[];
/**
* Import cookies from Chrome's Cookies SQLite database
*/
export declare function importChromeCookies(options?: {
profile?: string;
domain?: string;
}): ChromeCookie[];
/**
* Convert ChromeCookie to Playwright cookie format
*/
export declare function toPlaywrightCookie(cookie: ChromeCookie): {
name: string;
value: string;
domain: string;
path: string;
expires: number;
secure: boolean;
httpOnly: boolean;
sameSite: 'Strict' | 'Lax' | 'None';
};
//# sourceMappingURL=chrome.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"chrome.d.ts","sourceRoot":"","sources":["../src/chrome.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AASH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;CACrC;AA8ED;;GAEG;AACH,wBAAgB,kBAAkB,IAAI;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CA4BrE;AAgDD;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,CAAC,EAAE;IAC5C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,YAAY,EAAE,CA6CjB;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,YAAY,GAAG;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;CACrC,CAgBA"}
+217
View File
@@ -0,0 +1,217 @@
/**
* Chrome cookie importer (macOS)
*
* Reads cookies from Chrome's SQLite database and decrypts values using
* the encryption key stored in the macOS Keychain ("Chrome Safe Storage").
*
* Encryption scheme (macOS):
* - Keychain password → PBKDF2(password, salt="saltysalt", iterations=1003, keylen=16)
* - AES-128-CBC with IV = 16 bytes of 0x20 (space)
* - Encrypted values prefixed with "v10" (3 bytes)
*
* Chrome timestamps are microseconds since Jan 1, 1601 (Windows/WebKit epoch).
* Database is copied to a temp directory to avoid WAL lock conflicts.
*/
import { execSync } from 'node:child_process';
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { homedir, platform, tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
// Chrome epoch (Jan 1, 1601) to Unix epoch (Jan 1, 1970) offset in microseconds
const CHROME_EPOCH_OFFSET = 11644473600000000n;
/**
* Convert Chrome timestamp (microseconds since Jan 1, 1601) to Unix seconds
*/
function chromeToUnix(chromeTime) {
if (chromeTime === 0n)
return 0;
const unixMicro = chromeTime - CHROME_EPOCH_OFFSET;
return Number(unixMicro / 1000000n);
}
/**
* Get Chrome encryption key from macOS Keychain, derive AES key via PBKDF2
*/
function getDerivedKey() {
if (platform() !== 'darwin') {
throw new Error('Chrome cookie decryption is currently only supported on macOS');
}
const keychainPassword = execSync('security find-generic-password -s "Chrome Safe Storage" -w', {
encoding: 'utf-8',
}).trim();
return pbkdf2Sync(keychainPassword, 'saltysalt', 1003, 16, 'sha1');
}
/**
* Decrypt a Chrome encrypted cookie value
*/
function decryptValue(encrypted, derivedKey) {
if (encrypted.length === 0)
return '';
// Check for "v10" prefix (macOS encryption marker)
const prefix = encrypted.subarray(0, 3).toString('ascii');
if (prefix !== 'v10') {
// Not encrypted or unknown format — return as-is
return encrypted.toString('utf-8');
}
const ciphertext = encrypted.subarray(3);
if (ciphertext.length === 0)
return '';
// AES-128-CBC, IV is 16 bytes of 0x20 (space)
const iv = Buffer.alloc(16, 0x20);
const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv);
try {
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString('utf-8');
}
catch {
return '';
}
}
/**
* Get the Chrome user data directory for the current platform
*/
function getChromeRoot() {
const home = homedir();
switch (platform()) {
case 'darwin':
return join(home, 'Library/Application Support/Google/Chrome');
case 'linux':
return join(home, '.config/google-chrome');
case 'win32':
return join(process.env.LOCALAPPDATA || join(home, 'AppData/Local'), 'Google/Chrome/User Data');
default:
throw new Error(`Unsupported platform: ${platform()}`);
}
}
/**
* List available Chrome profiles
*/
export function listChromeProfiles() {
const root = getChromeRoot();
if (!existsSync(root))
return [];
const profiles = [];
// Check "Default" profile
const defaultCookies = join(root, 'Default', 'Cookies');
if (existsSync(defaultCookies)) {
profiles.push({ name: 'Default', path: 'Default' });
}
// Check numbered profiles (Profile 1, Profile 2, ...)
try {
const entries = readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('Profile ')) {
const cookiesPath = join(root, entry.name, 'Cookies');
if (existsSync(cookiesPath)) {
profiles.push({ name: entry.name, path: entry.name });
}
}
}
}
catch {
// Ignore readdir errors
}
return profiles;
}
/**
* Safely copy the Chrome cookies database to a temp directory.
* Copies Cookies + WAL + SHM files to avoid lock conflicts.
*/
function copyDatabaseSafely(dbPath) {
if (!existsSync(dbPath)) {
throw new Error(`Chrome cookies database not found at: ${dbPath}\nMake sure Chrome has been used at least once.`);
}
const tmpDir = mkdtempSync(join(tmpdir(), 'browse-cr-'));
const dbName = 'Cookies';
try {
copyFileSync(dbPath, join(tmpDir, dbName));
for (const ext of ['-wal', '-shm']) {
const src = `${dbPath}${ext}`;
if (existsSync(src)) {
copyFileSync(src, join(tmpDir, `${dbName}${ext}`));
}
}
return { tmpDir, tmpDbPath: join(tmpDir, dbName) };
}
catch (err) {
rmSync(tmpDir, { recursive: true, force: true });
throw err;
}
}
/**
* Convert Chrome sameSite integer to string
* -1 = unspecified (treat as None), 0 = None (was "no_restriction"), 1 = Lax, 2 = Strict
*/
function sameSiteToString(value) {
switch (value) {
case 2:
return 'Strict';
case 1:
return 'Lax';
default:
return 'None';
}
}
/**
* Import cookies from Chrome's Cookies SQLite database
*/
export function importChromeCookies(options) {
const root = getChromeRoot();
const profileDir = options?.profile || 'Default';
const dbPath = join(root, profileDir, 'Cookies');
const { tmpDir, tmpDbPath } = copyDatabaseSafely(dbPath);
try {
const derivedKey = getDerivedKey();
const db = new DatabaseSync(tmpDbPath, { readOnly: true });
let query = 'SELECT name, value, encrypted_value, host_key, path, CAST(expires_utc AS TEXT) as expires_utc_str, is_secure, is_httponly, samesite FROM cookies';
const params = [];
if (options?.domain) {
const domain = options.domain.toLowerCase();
query += ' WHERE LOWER(host_key) = ? OR LOWER(host_key) = ? OR LOWER(host_key) LIKE ?';
params.push(domain, `.${domain}`, `%.${domain}`);
}
const stmt = db.prepare(query);
const rows = params.length > 0 ? stmt.all(...params) : stmt.all();
db.close();
return rows.map((row) => {
// Use plaintext value if available, otherwise decrypt
let value = row.value;
if (!value && row.encrypted_value) {
value = decryptValue(row.encrypted_value, derivedKey);
}
return {
name: row.name,
value,
domain: row.host_key,
path: row.path,
expires: chromeToUnix(BigInt(row.expires_utc_str)),
secure: row.is_secure === 1,
httpOnly: row.is_httponly === 1,
sameSite: sameSiteToString(row.samesite),
};
});
}
finally {
rmSync(tmpDir, { recursive: true, force: true });
}
}
/**
* Convert ChromeCookie to Playwright cookie format
*/
export function toPlaywrightCookie(cookie) {
let expires = cookie.expires;
if (expires <= 0) {
expires = -1;
}
return {
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
expires,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite,
};
}
//# sourceMappingURL=chrome.js.map
+1
View File
File diff suppressed because one or more lines are too long
Vendored
+12
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util'; import { parseArgs } from 'node:util';
import { ClaudeBrowser } from './browser.js'; import { ClaudeBrowser } from './browser.js';
import * as image from './image.js'; import * as image from './image.js';
import { startServer } from './server.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8')); const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
const { values, positionals } = parseArgs({ const { values, positionals } = parseArgs({
@@ -24,6 +25,7 @@ const { values, positionals } = parseArgs({
json: { type: 'boolean', short: 'j', default: false }, json: { type: 'boolean', short: 'j', default: false },
click: { type: 'string', short: 'c', multiple: true }, click: { type: 'string', short: 'c', multiple: true },
type: { type: 'string', short: 't', multiple: true }, type: { type: 'string', short: 't', multiple: true },
server: { type: 'string', short: 's' },
help: { type: 'boolean', default: false }, help: { type: 'boolean', default: false },
version: { type: 'boolean', short: 'v', default: false }, version: { type: 'boolean', short: 'v', default: false },
// Image processing options // Image processing options
@@ -51,6 +53,7 @@ Options:
-j, --json Output query results as JSON -j, --json Output query results as JSON
-c, --click <selector> Click on element (can be repeated for multiple clicks) -c, --click <selector> Click on element (can be repeated for multiple clicks)
-t, --type <sel>=<text> Type text into input (can be repeated) -t, --type <sel>=<text> Type text into input (can be repeated)
-s, --server <port> Start HTTP server mode (default port: 13373)
-v, --version Show version -v, --version Show version
--help Show this help --help Show this help
@@ -78,6 +81,10 @@ Image processing examples:
browse https://example.com --resize 800x600 browse https://example.com --resize 800x600
browse https://example.com --compress 60 browse https://example.com --compress 60
Server mode:
browse -s 13373 # Start HTTP server on port 13373
curl localhost:13373 -d '{"cmd":"goto","url":"https://example.com"}'
MCP Server (for Claude Code integration): MCP Server (for Claude Code integration):
browse-mcp # Run as MCP server (stdio transport) browse-mcp # Run as MCP server (stdio transport)
`; `;
@@ -219,6 +226,11 @@ async function main() {
console.log(HELP); console.log(HELP);
process.exit(0); process.exit(0);
} }
if (values.server !== undefined) {
const port = Number.parseInt(values.server) || 13373;
await startServer({ port });
return;
}
if (positionals.length === 0) { if (positionals.length === 0) {
console.log(HELP); console.log(HELP);
process.exit(0); process.exit(0);
+1 -1
View File
File diff suppressed because one or more lines are too long
+96
View File
@@ -0,0 +1,96 @@
/**
* Browse configuration and session persistence
*
* Config: ~/.config/browse/config.json — user defaults for launch options
* Session: ~/.config/browse/session.json — persistent cookies, storage, last URL
*
* Config is loaded once at startup and merged under explicit tool args.
* Session is auto-saved on close and auto-restored on launch (if present).
*/
export interface BrowseConfig {
/** Launch headless (default: true) */
headless?: boolean;
/** Default viewport width (default: 1280) */
width?: number;
/** Default viewport height (default: 800) */
height?: number;
/** Launch in macOS native fullscreen (default: false) */
fullscreen?: boolean;
/** Enable preview mode — highlight elements before actions (default: false) */
preview?: boolean;
/** Preview highlight duration in ms (default: 2000) */
previewDelay?: number;
/** Enable stealth mode to reduce bot detection (default: false) */
stealth?: boolean;
/** Auto-restore session on launch (default: true) */
autoRestore?: boolean;
/** Auto-save session on close (default: true) */
autoSave?: boolean;
/** Default browser to import cookies from on first launch */
importFrom?: 'safari' | 'firefox' | 'chrome';
/** Default domain filter for cookie import */
importDomain?: string;
}
/**
* Load config from ~/.config/browse/config.json
* Returns defaults merged with user config. Missing file = all defaults.
*/
export declare function loadConfig(): Required<BrowseConfig>;
/**
* Save config to ~/.config/browse/config.json
*/
export declare function saveConfig(config: Partial<BrowseConfig>): void;
/**
* Get the config file path (for display/debugging)
*/
export declare function getConfigPath(): string;
export interface BrowseSession {
/** Last visited URL */
url?: string;
/** Page title at save time */
title?: string;
/** All cookies from the browser context */
cookies?: Array<{
name: string;
value: string;
domain: string;
path: string;
expires: number;
secure: boolean;
httpOnly: boolean;
sameSite: 'Strict' | 'Lax' | 'None';
}>;
/** localStorage key-value pairs (per origin) */
localStorage?: Record<string, string>;
/** sessionStorage key-value pairs (per origin) */
sessionStorage?: Record<string, string>;
/** ISO timestamp of last save */
savedAt?: string;
}
/**
* Load session from ~/.config/browse/session.json
* Returns null if no session file exists.
*/
export declare function loadSession(): BrowseSession | null;
/**
* Save session to ~/.config/browse/session.json
*/
export declare function saveSession(session: BrowseSession): void;
/**
* Delete the session file
*/
export declare function clearSession(): void;
/**
* Get the session file path (for display/debugging)
*/
export declare function getSessionPath(): string;
/**
* Import cookies from all available browsers, deduplicate, and save to session.json.
* Dedup key: domain + name + path. Last-write wins (Chrome > Firefox > Safari priority).
* Returns the merged cookie count.
*/
export declare function importAllToSession(): Promise<{
total: number;
sources: Record<string, number>;
}>;
//# sourceMappingURL=config.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAYH,MAAM,WAAW,YAAY;IAC3B,sCAAsC;IACtC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,6CAA6C;IAC7C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yDAAyD;IACzD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,uDAAuD;IACvD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mEAAmE;IACnE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qDAAqD;IACrD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC7C,8CAA8C;IAC9C,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAsBD;;;GAGG;AACH,wBAAgB,UAAU,IAAI,QAAQ,CAAC,YAAY,CAAC,CAUnD;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,OAAO,CAAC,YAAY,CAAC,GAAG,IAAI,CAG9D;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAID,MAAM,WAAW,aAAa;IAC5B,uBAAuB;IACvB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2CAA2C;IAC3C,OAAO,CAAC,EAAE,KAAK,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,OAAO,CAAC;QAChB,QAAQ,EAAE,OAAO,CAAC;QAClB,QAAQ,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;KACrC,CAAC,CAAC;IACH,gDAAgD;IAChD,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,kDAAkD;IAClD,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAgB,WAAW,IAAI,aAAa,GAAG,IAAI,CASlD;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI,CAIxD;AAED;;GAEG;AACH,wBAAgB,YAAY,IAAI,IAAI,CAKnC;AAED;;GAEG;AACH,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAeD;;;;GAIG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC,CAAC,CAgED"}
+165
View File
@@ -0,0 +1,165 @@
/**
* Browse configuration and session persistence
*
* Config: ~/.config/browse/config.json — user defaults for launch options
* Session: ~/.config/browse/session.json — persistent cookies, storage, last URL
*
* Config is loaded once at startup and merged under explicit tool args.
* Session is auto-saved on close and auto-restored on launch (if present).
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
const CONFIG_DIR = join(homedir(), '.config', 'browse');
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
const SESSION_PATH = join(CONFIG_DIR, 'session.json');
const DEFAULTS = {
headless: false,
width: 1280,
height: 800,
fullscreen: true,
preview: true,
previewDelay: 2000,
stealth: true,
autoRestore: true,
autoSave: true,
importFrom: 'safari',
importDomain: '',
};
function ensureDir() {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true });
}
}
/**
* Load config from ~/.config/browse/config.json
* Returns defaults merged with user config. Missing file = all defaults.
*/
export function loadConfig() {
if (!existsSync(CONFIG_PATH))
return { ...DEFAULTS };
try {
const raw = readFileSync(CONFIG_PATH, 'utf-8');
const user = JSON.parse(raw);
return { ...DEFAULTS, ...user };
}
catch {
return { ...DEFAULTS };
}
}
/**
* Save config to ~/.config/browse/config.json
*/
export function saveConfig(config) {
ensureDir();
writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`);
}
/**
* Get the config file path (for display/debugging)
*/
export function getConfigPath() {
return CONFIG_PATH;
}
/**
* Load session from ~/.config/browse/session.json
* Returns null if no session file exists.
*/
export function loadSession() {
if (!existsSync(SESSION_PATH))
return null;
try {
const raw = readFileSync(SESSION_PATH, 'utf-8');
return JSON.parse(raw);
}
catch {
return null;
}
}
/**
* Save session to ~/.config/browse/session.json
*/
export function saveSession(session) {
ensureDir();
session.savedAt = new Date().toISOString();
writeFileSync(SESSION_PATH, `${JSON.stringify(session, null, 2)}\n`);
}
/**
* Delete the session file
*/
export function clearSession() {
if (existsSync(SESSION_PATH)) {
const { unlinkSync } = require('node:fs');
unlinkSync(SESSION_PATH);
}
}
/**
* Get the session file path (for display/debugging)
*/
export function getSessionPath() {
return SESSION_PATH;
}
/**
* Import cookies from all available browsers, deduplicate, and save to session.json.
* Dedup key: domain + name + path. Last-write wins (Chrome > Firefox > Safari priority).
* Returns the merged cookie count.
*/
export async function importAllToSession() {
const all = [];
const sources = {};
// Safari (async — binary parser)
try {
const { importSafariCookies, toPlaywrightCookie } = await import('./safari.js');
const cookies = await importSafariCookies();
const converted = cookies.map(toPlaywrightCookie);
all.push(...converted);
sources.safari = cookies.length;
}
catch {
sources.safari = 0;
}
// Firefox (sync — SQLite)
try {
const { importFirefoxCookies, listFirefoxProfiles, toPlaywrightCookie } = await import('./firefox.js');
// Try default-release first (main profile on macOS), fall back to default
const profiles = listFirefoxProfiles();
const profile = profiles.find((p) => p.name === 'default-release') ||
profiles.find((p) => p.isDefault) ||
profiles[0];
if (profile) {
const cookies = importFirefoxCookies({ profile: profile.name });
const converted = cookies.map(toPlaywrightCookie);
all.push(...converted);
sources.firefox = cookies.length;
}
else {
sources.firefox = 0;
}
}
catch {
sources.firefox = 0;
}
// Chrome (sync — SQLite + Keychain decryption, macOS only)
try {
const { importChromeCookies, toPlaywrightCookie } = await import('./chrome.js');
const cookies = importChromeCookies();
const converted = cookies.map(toPlaywrightCookie);
all.push(...converted);
sources.chrome = cookies.length;
}
catch {
sources.chrome = 0;
}
// Deduplicate: last-write wins (Chrome overwrites Firefox overwrites Safari)
const seen = new Map();
for (const cookie of all) {
const key = `${cookie.domain}|${cookie.name}|${cookie.path}`;
seen.set(key, cookie);
}
const deduped = [...seen.values()];
// Save to session.json
saveSession({
cookies: deduped,
});
return { total: deduped.length, sources };
}
//# sourceMappingURL=config.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC7E,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,QAAQ,CAAC,CAAC;AACxD,MAAM,WAAW,GAAG,IAAI,CAAC,UAAU,EAAE,aAAa,CAAC,CAAC;AACpD,MAAM,YAAY,GAAG,IAAI,CAAC,UAAU,EAAE,cAAc,CAAC,CAAC;AA6BtD,MAAM,QAAQ,GAA2B;IACvC,QAAQ,EAAE,KAAK;IACf,KAAK,EAAE,IAAI;IACX,MAAM,EAAE,GAAG;IACX,UAAU,EAAE,IAAI;IAChB,OAAO,EAAE,IAAI;IACb,YAAY,EAAE,IAAI;IAClB,OAAO,EAAE,IAAI;IACb,WAAW,EAAE,IAAI;IACjB,QAAQ,EAAE,IAAI;IACd,UAAU,EAAE,QAAQ;IACpB,YAAY,EAAE,EAAE;CACjB,CAAC;AAEF,SAAS,SAAS;IAChB,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;QAC5B,SAAS,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC7C,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,UAAU;IACxB,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC;QAAE,OAAO,EAAE,GAAG,QAAQ,EAAE,CAAC;IAErD,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,YAAY,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;QAC/C,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAA0B,CAAC;QACtD,OAAO,EAAE,GAAG,QAAQ,EAAE,GAAG,IAAI,EAAE,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,GAAG,QAAQ,EAAE,CAAC;IACzB,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,UAAU,CAAC,MAA6B;IACtD,SAAS,EAAE,CAAC;IACZ,aAAa,CAAC,WAAW,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;AACrE,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,aAAa;IAC3B,OAAO,WAAW,CAAC;AACrB,CAAC;AA4BD;;;GAGG;AACH,MAAM,UAAU,WAAW;IACzB,IAAI,CAAC,UAAU,CAAC,YAAY,CAAC;QAAE,OAAO,IAAI,CAAC;IAE3C,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,YAAY,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC;QAChD,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAkB,CAAC;IAC1C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,WAAW,CAAC,OAAsB;IAChD,SAAS,EAAE,CAAC;IACZ,OAAO,CAAC,OAAO,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC3C,aAAa,CAAC,YAAY,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;AACvE,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,YAAY;IAC1B,IAAI,UAAU,CAAC,YAAY,CAAC,EAAE,CAAC;QAC7B,MAAM,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;QAC1C,UAAU,CAAC,YAAY,CAAC,CAAC;IAC3B,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,cAAc;IAC5B,OAAO,YAAY,CAAC;AACtB,CAAC;AAeD;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB;IAItC,MAAM,GAAG,GAAuB,EAAE,CAAC;IACnC,MAAM,OAAO,GAA2B,EAAE,CAAC;IAE3C,iCAAiC;IACjC,IAAI,CAAC;QACH,MAAM,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,CAAC;QAChF,MAAM,OAAO,GAAG,MAAM,mBAAmB,EAAE,CAAC;QAC5C,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;QAClD,GAAG,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,CAAC;QACvB,OAAO,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC;IACrB,CAAC;IAED,0BAA0B;IAC1B,IAAI,CAAC;QACH,MAAM,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,GAAG,MAAM,MAAM,CACpF,cAAc,CACf,CAAC;QACF,0EAA0E;QAC1E,MAAM,QAAQ,GAAG,mBAAmB,EAAE,CAAC;QACvC,MAAM,OAAO,GACX,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,iBAAiB,CAAC;YAClD,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;YACjC,QAAQ,CAAC,CAAC,CAAC,CAAC;QACd,IAAI,OAAO,EAAE,CAAC;YACZ,MAAM,OAAO,GAAG,oBAAoB,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;YAChE,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;YAClD,GAAG,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,CAAC;YACvB,OAAO,CAAC,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC;QACnC,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,OAAO,GAAG,CAAC,CAAC;QACtB,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,OAAO,GAAG,CAAC,CAAC;IACtB,CAAC;IAED,2DAA2D;IAC3D,IAAI,CAAC;QACH,MAAM,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,CAAC;QAChF,MAAM,OAAO,GAAG,mBAAmB,EAAE,CAAC;QACtC,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;QAClD,GAAG,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,CAAC;QACvB,OAAO,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC;IACrB,CAAC;IAED,6EAA6E;IAC7E,MAAM,IAAI,GAAG,IAAI,GAAG,EAA4B,CAAC;IACjD,KAAK,MAAM,MAAM,IAAI,GAAG,EAAE,CAAC;QACzB,MAAM,GAAG,GAAG,GAAG,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;QAC7D,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACxB,CAAC;IAED,MAAM,OAAO,GAAG,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;IAEnC,uBAAuB;IACvB,WAAW,CAAC;QACV,OAAO,EAAE,OAAO;KACjB,CAAC,CAAC;IAEH,OAAO,EAAE,KAAK,EAAE,OAAO,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;AAC5C,CAAC"}
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"firefox.d.ts","sourceRoot":"","sources":["../src/firefox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAOH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;CACrC;AAED,UAAU,cAAc;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,OAAO,CAAC;CACpB;AAuFD;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,cAAc,EAAE,CAItD;AAsFD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,CAAC,EAAE;IAC7C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,aAAa,EAAE,CAmClB;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,aAAa,GAAG;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;CACrC,CAWA"} {"version":3,"file":"firefox.d.ts","sourceRoot":"","sources":["../src/firefox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAOH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;CACrC;AAED,UAAU,cAAc;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,OAAO,CAAC;CACpB;AAuFD;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,cAAc,EAAE,CAItD;AAsFD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,CAAC,EAAE;IAC7C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,aAAa,EAAE,CAmClB;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,aAAa,GAAG;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;CACrC,CA6BA"}
+18 -2
View File
@@ -215,12 +215,28 @@ export function importFirefoxCookies(options) {
* Convert FirefoxCookie to Playwright cookie format * Convert FirefoxCookie to Playwright cookie format
*/ */
export function toPlaywrightCookie(cookie) { export function toPlaywrightCookie(cookie) {
// Firefox uses 0 for session cookies; Playwright requires -1 or positive unix timestamp (seconds).
// Some Firefox cookies store expiry in milliseconds instead of seconds — detect and convert.
// Any expiry > year 2100 in seconds (4102444800) is likely milliseconds.
let expires = cookie.expires;
if (expires > 4102444800) {
expires = Math.floor(expires / 1000);
}
if (expires <= 0) {
expires = -1;
}
// Normalize domain: Firefox sometimes stores ".www.example.com" which Playwright
// won't match for "www.example.com". Strip ".www." prefix to ".example.com".
let domain = cookie.domain;
if (domain.startsWith('.www.')) {
domain = domain.slice(4); // ".www.example.com" -> ".example.com"
}
return { return {
name: cookie.name, name: cookie.name,
value: cookie.value, value: cookie.value,
domain: cookie.domain, domain,
path: cookie.path, path: cookie.path,
expires: cookie.expires, expires,
secure: cookie.secure, secure: cookie.secure,
httpOnly: cookie.httpOnly, httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite, sameSite: cookie.sameSite,
+1 -1
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -1,4 +1,7 @@
export { ClaudeBrowser } from './browser.js'; export { ClaudeBrowser } from './browser.js';
export { importChromeCookies, listChromeProfiles, type ChromeCookie } from './chrome.js';
export { loadConfig, saveConfig, loadSession, saveSession, clearSession, importAllToSession, getConfigPath, getSessionPath, type BrowseConfig, type BrowseSession, } from './config.js';
export { BrowserServer, startServer, type ServerOptions } from './server.js';
export { createFavicon, convert, resize, crop, compress, thumbnail, type FaviconResult, type ImageResult, type FitType, type FormatType, type ThumbnailSize, } from './image.js'; export { createFavicon, convert, resize, crop, compress, thumbnail, type FaviconResult, type ImageResult, type FitType, type FormatType, type ThumbnailSize, } from './image.js';
export type { BrowserOptions, BrowserCommand, CommandResponse, ElementInfo, SuccessResponse, ErrorResponse, GotoCommand, ClickCommand, TypeCommand, QueryCommand, ScreenshotCommand, UrlCommand, HtmlCommand, BackCommand, ForwardCommand, ReloadCommand, WaitCommand, NewPageCommand, CloseCommand, EvalCommand, FaviconCommand, ConvertCommand, ResizeCommand, CropCommand, CompressCommand, ThumbnailCommand, } from './types.js'; export type { BrowserOptions, BrowserCommand, CommandResponse, ElementInfo, SuccessResponse, ErrorResponse, GotoCommand, ClickCommand, TypeCommand, QueryCommand, ScreenshotCommand, UrlCommand, HtmlCommand, BackCommand, ForwardCommand, ReloadCommand, WaitCommand, NewPageCommand, CloseCommand, EvalCommand, FaviconCommand, ConvertCommand, ResizeCommand, CropCommand, CompressCommand, ThumbnailCommand, } from './types.js';
//# sourceMappingURL=index.d.ts.map //# sourceMappingURL=index.d.ts.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EACL,aAAa,EACb,OAAO,EACP,MAAM,EACN,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,UAAU,EACf,KAAK,aAAa,GACnB,MAAM,YAAY,CAAC;AACpB,YAAY,EACV,cAAc,EACd,cAAc,EACd,eAAe,EACf,WAAW,EACX,eAAe,EACf,aAAa,EACb,WAAW,EACX,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,iBAAiB,EACjB,UAAU,EACV,WAAW,EACX,WAAW,EACX,cAAc,EACd,aAAa,EACb,WAAW,EACX,cAAc,EACd,YAAY,EACZ,WAAW,EACX,cAAc,EACd,cAAc,EACd,aAAa,EACb,WAAW,EACX,eAAe,EACf,gBAAgB,GACjB,MAAM,YAAY,CAAC"} {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AACzF,OAAO,EACL,UAAU,EACV,UAAU,EACV,WAAW,EACX,WAAW,EACX,YAAY,EACZ,kBAAkB,EAClB,aAAa,EACb,cAAc,EACd,KAAK,YAAY,EACjB,KAAK,aAAa,GACnB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAC7E,OAAO,EACL,aAAa,EACb,OAAO,EACP,MAAM,EACN,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,UAAU,EACf,KAAK,aAAa,GACnB,MAAM,YAAY,CAAC;AACpB,YAAY,EACV,cAAc,EACd,cAAc,EACd,eAAe,EACf,WAAW,EACX,eAAe,EACf,aAAa,EACb,WAAW,EACX,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,iBAAiB,EACjB,UAAU,EACV,WAAW,EACX,WAAW,EACX,cAAc,EACd,aAAa,EACb,WAAW,EACX,cAAc,EACd,YAAY,EACZ,WAAW,EACX,cAAc,EACd,cAAc,EACd,aAAa,EACb,WAAW,EACX,eAAe,EACf,gBAAgB,GACjB,MAAM,YAAY,CAAC"}
+3
View File
@@ -1,3 +1,6 @@
export { ClaudeBrowser } from './browser.js'; export { ClaudeBrowser } from './browser.js';
export { importChromeCookies, listChromeProfiles } from './chrome.js';
export { loadConfig, saveConfig, loadSession, saveSession, clearSession, importAllToSession, getConfigPath, getSessionPath, } from './config.js';
export { BrowserServer, startServer } from './server.js';
export { createFavicon, convert, resize, crop, compress, thumbnail, } from './image.js'; export { createFavicon, convert, resize, crop, compress, thumbnail, } from './image.js';
//# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EACL,aAAa,EACb,OAAO,EACP,MAAM,EACN,IAAI,EACJ,QAAQ,EACR,SAAS,GAMV,MAAM,YAAY,CAAC"} {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,mBAAmB,EAAE,kBAAkB,EAAqB,MAAM,aAAa,CAAC;AACzF,OAAO,EACL,UAAU,EACV,UAAU,EACV,WAAW,EACX,WAAW,EACX,YAAY,EACZ,kBAAkB,EAClB,aAAa,EACb,cAAc,GAGf,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAsB,MAAM,aAAa,CAAC;AAC7E,OAAO,EACL,aAAa,EACb,OAAO,EACP,MAAM,EACN,IAAI,EACJ,QAAQ,EACR,SAAS,GAMV,MAAM,YAAY,CAAC"}
Vendored
+124 -11
View File
@@ -6,19 +6,21 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod'; import { z } from 'zod';
import { ClaudeBrowser } from './browser.js'; import { ClaudeBrowser } from './browser.js';
import { importAllToSession, loadConfig, loadSession, saveSession, } from './config.js';
import * as image from './image.js'; import * as image from './image.js';
import { stderrLogger as log } from './logger.js'; import { stderrLogger as log } from './logger.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8')); const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
// Browser options configurable via launch tool // Load user config from ~/.config/browse/config.json
const userConfig = loadConfig();
let browserOptions = { let browserOptions = {
headless: true, headless: userConfig.headless,
width: 1280, width: userConfig.width,
height: 800, height: userConfig.height,
fullscreen: false, fullscreen: userConfig.fullscreen,
preview: false, preview: userConfig.preview,
previewDelay: 2000, previewDelay: userConfig.previewDelay,
stealth: false, stealth: userConfig.stealth,
}; };
let browser = new ClaudeBrowser(browserOptions); let browser = new ClaudeBrowser(browserOptions);
let launched = false; let launched = false;
@@ -27,6 +29,56 @@ async function ensureLaunched() {
if (!launched) { if (!launched) {
await browser.launch(); await browser.launch();
launched = true; launched = true;
// Auto-restore session if enabled
// If no session.json exists, import all browser cookies first
if (userConfig.autoRestore) {
let session = loadSession();
if (!session) {
try {
const result = await importAllToSession();
log.command({
cmd: 'auto_import',
url: `${result.total} cookies from ${Object.entries(result.sources)
.filter(([, v]) => v > 0)
.map(([k, v]) => `${k}:${v}`)
.join(', ')}`,
});
session = loadSession();
}
catch (err) {
log.result({ cmd: 'auto_import' }, { ok: false, error: err.message });
}
}
if (session) {
try {
const context = browser.getContext();
if (context && session.cookies?.length) {
await context.addCookies(session.cookies);
}
const page = browser.getPage();
if (page && session.url && session.url !== 'about:blank') {
await page.goto(session.url, { waitUntil: 'domcontentloaded' });
await Promise.race([
page.waitForLoadState('networkidle'),
page.waitForTimeout(5000),
]).catch(() => { });
// Restore localStorage/sessionStorage after navigation
if (session.localStorage || session.sessionStorage) {
const local = session.localStorage || {};
const sessionStorage = session.sessionStorage || {};
await page.evaluate(`((data) => {
for (const [k, v] of Object.entries(data.local)) localStorage.setItem(k, v);
for (const [k, v] of Object.entries(data.session)) sessionStorage.setItem(k, v);
})({ local: ${JSON.stringify(local)}, session: ${JSON.stringify(sessionStorage)} })`);
}
}
log.command({ cmd: 'auto_restore', url: session.url });
}
catch (err) {
log.result({ cmd: 'auto_restore' }, { ok: false, error: err.message });
}
}
}
} }
} }
function textResult(text) { function textResult(text) {
@@ -168,6 +220,31 @@ server.tool('screenshot', 'Take a screenshot of the current page', {
const result = await browser.screenshot(path, fullPage); const result = await browser.screenshot(path, fullPage);
return textResult(JSON.stringify({ ok: true, path: result.path })); return textResult(JSON.stringify({ ok: true, path: result.path }));
})); }));
// Preview — navigate + screenshot in one call, optional POST to preview endpoint
server.tool('preview', 'Navigate to a URL and take a screenshot in one call. Optionally POST the result to a preview endpoint.', {
url: z.string().describe('URL or file:///path to preview'),
width: z.number().optional().default(1280).describe('Viewport width'),
height: z.number().optional().default(800).describe('Viewport height'),
fullPage: z.boolean().optional().default(false),
output: z.string().optional().default('/tmp/preview.png').describe('Screenshot output path'),
previewUrl: z.string().optional().describe('HTTP endpoint to POST screenshot result to'),
title: z.string().optional().describe('Title sent with preview POST'),
caption: z.string().optional().describe('Caption sent with preview POST'),
}, withLogging('preview', async ({ url, width, height, fullPage, output, previewUrl, title, caption }) => {
await ensureLaunched();
const result = await browser.executeCommand({
cmd: 'preview',
url,
width,
height,
fullPage,
output,
previewUrl,
title,
caption,
});
return textResult(JSON.stringify(result));
}));
// Eval // Eval
server.tool('eval', 'Execute JavaScript in the browser context', { script: z.string() }, withLogging('eval', async ({ script }) => { server.tool('eval', 'Execute JavaScript in the browser context', { script: z.string() }, withLogging('eval', async ({ script }) => {
await ensureLaunched(); await ensureLaunched();
@@ -374,6 +451,41 @@ server.tool('wait', 'Wait for a specified time in milliseconds', { ms: z.number(
// Session management // Session management
server.tool('close', 'Close the browser and end the current session', {}, withLogging('close', async () => { server.tool('close', 'Close the browser and end the current session', {}, withLogging('close', async () => {
if (launched) { if (launched) {
// Auto-save session before closing
if (userConfig.autoSave) {
try {
const page = browser.getPage();
const context = browser.getContext();
if (page && context) {
const url = page.url();
const title = await page.title();
const cookies = await context.cookies();
const storage = (await page.evaluate(`({
localStorage: Object.fromEntries(
Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i))
.filter(k => k !== null)
.map(k => [k, localStorage.getItem(k) || ''])
),
sessionStorage: Object.fromEntries(
Array.from({ length: sessionStorage.length }, (_, i) => sessionStorage.key(i))
.filter(k => k !== null)
.map(k => [k, sessionStorage.getItem(k) || ''])
),
})`));
saveSession({
url,
title,
cookies,
localStorage: storage.localStorage,
sessionStorage: storage.sessionStorage,
});
log.command({ cmd: 'auto_save', url });
}
}
catch (err) {
log.result({ cmd: 'auto_save' }, { ok: false, error: err.message });
}
}
await browser.close(); await browser.close();
launched = false; launched = false;
} }
@@ -437,7 +549,8 @@ server.tool('session_restore', 'Restore a previously saved session state from a
} }
// Navigate to saved URL // Navigate to saved URL
if (data.url) { if (data.url) {
await page.goto(data.url, { waitUntil: 'networkidle' }); await page.goto(data.url, { waitUntil: 'domcontentloaded' });
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(() => { });
} }
// Restore storage (runs in browser context) // Restore storage (runs in browser context)
const local = data.localStorage || {}; const local = data.localStorage || {};
@@ -455,8 +568,8 @@ server.tool('session_restore', 'Restore a previously saved session state from a
})); }));
})); }));
// Browser import // Browser import
server.tool('import', 'Import cookies from Safari or Firefox browser. Safari requires Full Disk Access permission (macOS only). Firefox works on macOS, Linux, and Windows.', { server.tool('import', 'Import cookies from Safari, Firefox, or Chrome browser. Safari requires Full Disk Access (macOS). Chrome requires Keychain access (macOS). Firefox works on all platforms.', {
source: z.enum(['safari', 'firefox']).describe('Browser to import from'), source: z.enum(['safari', 'firefox', 'chrome']).describe('Browser to import from'),
domain: z domain: z
.string() .string()
.optional() .optional()
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -5
View File
@@ -4,17 +4,13 @@ export interface ServerOptions extends BrowserOptions {
} }
export declare class BrowserServer { export declare class BrowserServer {
private browser; private browser;
private app;
private server; private server;
private port; private port;
constructor(options?: ServerOptions); constructor(options?: ServerOptions);
private setupMiddleware; private handleRequest;
private setupRoutes;
private handleCommand;
start(): Promise<void>; start(): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
getPort(): number; getPort(): number;
getApp(): import("express-serve-static-core").Express;
} }
export declare function startServer(options?: ServerOptions): Promise<BrowserServer>; export declare function startServer(options?: ServerOptions): Promise<BrowserServer>;
//# sourceMappingURL=server.d.ts.map //# sourceMappingURL=server.d.ts.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAkB,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjE,MAAM,WAAW,aAAc,SAAQ,cAAc;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAuBD,qBAAa,aAAa;IACxB,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,GAAG,CAAa;IACxB,OAAO,CAAC,MAAM,CAAmD;IACjE,OAAO,CAAC,IAAI,CAAS;gBAET,OAAO,GAAE,aAAkB;IAOvC,OAAO,CAAC,eAAe;IAKvB,OAAO,CAAC,WAAW;YAIL,aAAa;IAsBrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAWtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAU3B,OAAO,IAAI,MAAM;IAIjB,MAAM;CAGP;AAED,wBAAsB,WAAW,CAAC,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,aAAa,CAAC,CAIrF"} {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAkB,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjE,MAAM,WAAW,aAAc,SAAQ,cAAc;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AA4CD,qBAAa,aAAa;IACxB,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,IAAI,CAAS;gBAET,OAAO,GAAE,aAAkB;YAKzB,aAAa;IA4CrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAgBtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAU3B,OAAO,IAAI,MAAM;CAGlB;AAED,wBAAsB,WAAW,CAAC,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,aAAa,CAAC,CAIrF"}
+50 -22
View File
@@ -1,11 +1,16 @@
import { createServer } from 'node:http';
import chalk from 'chalk'; import chalk from 'chalk';
import express from 'express';
import logSymbols from 'log-symbols'; import logSymbols from 'log-symbols';
import { ClaudeBrowser } from './browser.js'; import { ClaudeBrowser } from './browser.js';
import { logger, ts } from './logger.js'; import { logger, ts } from './logger.js';
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
function printBanner(port) { function printBanner(port) {
console.log(); console.log();
console.log(chalk.cyan.bold(' 🌐 Claude Browse Server')); console.log(chalk.cyan.bold(' browse server'));
console.log(chalk.dim(' ─────────────────────────')); console.log(chalk.dim(' ─────────────────────────'));
console.log(` ${logSymbols.success} Listening on ${chalk.bold(`http://localhost:${port}`)}`); console.log(` ${logSymbols.success} Listening on ${chalk.bold(`http://localhost:${port}`)}`);
console.log(); console.log();
@@ -14,51 +19,77 @@ function printBanner(port) {
console.log(` ${chalk.cyan('url')} ${chalk.blue('html')} ${chalk.yellow('back')} ${chalk.yellow('forward')} ${chalk.yellow('reload')} ${chalk.gray('wait')} ${chalk.red('close')}`); console.log(` ${chalk.cyan('url')} ${chalk.blue('html')} ${chalk.yellow('back')} ${chalk.yellow('forward')} ${chalk.yellow('reload')} ${chalk.gray('wait')} ${chalk.red('close')}`);
console.log(); console.log();
console.log(chalk.dim(' Example:')); console.log(chalk.dim(' Example:'));
console.log(chalk.gray(` curl -X POST localhost:${port} -d '{"cmd":"goto","url":"https://example.com"}'`)); console.log(chalk.gray(` curl localhost:${port} -d '{"cmd":"goto","url":"https://example.com"}'`));
console.log(); console.log();
} }
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
req.on('error', reject);
});
}
function sendJson(res, status, data) {
const body = JSON.stringify(data);
res.writeHead(status, { ...CORS_HEADERS, 'Content-Type': 'application/json' });
res.end(body);
}
export class BrowserServer { export class BrowserServer {
browser; browser;
app = express();
server = null; server = null;
port; port;
constructor(options = {}) { constructor(options = {}) {
this.browser = new ClaudeBrowser(options); this.browser = new ClaudeBrowser(options);
this.port = options.port ?? 13373; this.port = options.port ?? 13373;
this.setupMiddleware();
this.setupRoutes();
} }
setupMiddleware() { async handleRequest(req, res) {
this.app.use(express.json()); if (req.method === 'OPTIONS') {
this.app.use(express.text({ type: '*/*' })); res.writeHead(204, CORS_HEADERS);
} res.end();
setupRoutes() { return;
this.app.post('/', (req, res) => this.handleCommand(req, res)); }
} if (req.method !== 'POST' || req.url !== '/') {
async handleCommand(req, res) { sendJson(res, 404, { ok: false, error: 'POST / only' });
return;
}
try { try {
const cmd = typeof req.body === 'string' ? JSON.parse(req.body) : req.body; const raw = await readBody(req);
const cmd = JSON.parse(raw);
logger.command(cmd); logger.command(cmd);
if (cmd.cmd === 'close') { if (cmd.cmd === 'close') {
logger.result(cmd, { ok: true }); logger.result(cmd, { ok: true });
res.json({ ok: true }); sendJson(res, 200, { ok: true });
await this.stop(); await this.stop();
process.exit(0); process.exit(0);
} }
const result = await this.browser.executeCommand(cmd); const result = await this.browser.executeCommand(cmd);
logger.result(cmd, result); logger.result(cmd, result);
res.json(result); // For screenshot without a path, include base64 data
if (cmd.cmd === 'screenshot' && !cmd.path && result.ok) {
const page = this.browser.getPage();
if (page) {
const buffer = await page.screenshot();
result.data = buffer.toString('base64');
}
}
sendJson(res, 200, result);
} }
catch (err) { catch (err) {
const error = err.message; const error = err.message;
console.log(`${ts()} ${logSymbols.error} ${chalk.red(error)}`); console.log(`${ts()} ${logSymbols.error} ${chalk.red(error)}`);
res.status(500).json({ ok: false, error }); sendJson(res, 500, { ok: false, error });
} }
} }
async start() { async start() {
await this.browser.launch(); await this.browser.launch();
return new Promise((resolve) => { return new Promise((resolve) => {
this.server = this.app.listen(this.port, () => { this.server = createServer((req, res) => {
this.handleRequest(req, res).catch((err) => {
sendJson(res, 500, { ok: false, error: err.message });
});
});
this.server.listen(this.port, () => {
printBanner(this.port); printBanner(this.port);
resolve(); resolve();
}); });
@@ -76,9 +107,6 @@ export class BrowserServer {
getPort() { getPort() {
return this.port; return this.port;
} }
getApp() {
return this.app;
}
} }
export async function startServer(options = {}) { export async function startServer(options = {}) {
const server = new BrowserServer(options); const server = new BrowserServer(options);
+1 -1
View File
File diff suppressed because one or more lines are too long
+14 -2
View File
@@ -255,11 +255,22 @@ export interface EmulateCommand {
} }
export interface ImportCommand { export interface ImportCommand {
cmd: 'import'; cmd: 'import';
source: 'safari' | 'firefox'; source: 'safari' | 'firefox' | 'chrome';
domain?: string; domain?: string;
profile?: string; profile?: string;
} }
export type BrowserCommand = GotoCommand | ClickCommand | TypeCommand | QueryCommand | ScreenshotCommand | UrlCommand | HtmlCommand | BackCommand | ForwardCommand | ReloadCommand | WaitCommand | NewPageCommand | CloseCommand | EvalCommand | FaviconCommand | ConvertCommand | ResizeCommand | CropCommand | CompressCommand | ThumbnailCommand | ConsoleCommand | NetworkCommand | InterceptCommand | ErrorsCommand | MetricsCommand | A11yCommand | DialogCommand | CookiesCommand | StorageCommand | HoverCommand | SelectCommand | KeysCommand | UploadCommand | ScrollCommand | ViewportCommand | EmulateCommand | ImportCommand; export type BrowserCommand = GotoCommand | ClickCommand | TypeCommand | QueryCommand | ScreenshotCommand | UrlCommand | HtmlCommand | BackCommand | ForwardCommand | ReloadCommand | WaitCommand | NewPageCommand | CloseCommand | EvalCommand | FaviconCommand | ConvertCommand | ResizeCommand | CropCommand | CompressCommand | ThumbnailCommand | ConsoleCommand | NetworkCommand | InterceptCommand | ErrorsCommand | MetricsCommand | A11yCommand | DialogCommand | CookiesCommand | StorageCommand | HoverCommand | SelectCommand | KeysCommand | UploadCommand | ScrollCommand | ViewportCommand | EmulateCommand | ImportCommand | PreviewCommand;
export interface PreviewCommand {
cmd: 'preview';
url: string;
width?: number;
height?: number;
fullPage?: boolean;
output?: string;
previewUrl?: string;
title?: string;
caption?: string;
}
export interface SuccessResponse { export interface SuccessResponse {
ok: true; ok: true;
url?: string; url?: string;
@@ -269,6 +280,7 @@ export interface SuccessResponse {
count?: number; count?: number;
elements?: ElementInfo[]; elements?: ElementInfo[];
result?: unknown; result?: unknown;
posted?: boolean;
files?: string[]; files?: string[];
outputDir?: string; outputDir?: string;
width?: number; width?: number;
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@saiden/browse", "name": "@saiden/browse",
"version": "0.3.0", "version": "0.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@saiden/browse", "name": "@saiden/browse",
"version": "0.3.0", "version": "0.4.1",
"license": "BUSL-1.1", "license": "BUSL-1.1",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0", "@modelcontextprotocol/sdk": "^1.26.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@saiden/browse", "name": "@saiden/browse",
"version": "0.3.0", "version": "0.4.1",
"description": "Headless browser automation for Claude Code using Playwright WebKit", "description": "Headless browser automation for Claude Code using Playwright WebKit",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -42,7 +42,7 @@
"license": "BUSL-1.1", "license": "BUSL-1.1",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/saiden-dev/browse" "url": "git+https://github.com/saiden-dev/browse.git"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+97 -2
View File
@@ -4,6 +4,7 @@ import { promisify } from 'node:util';
import { type Browser, type BrowserContext, type Page, type Route, webkit } from 'playwright'; import { type Browser, type BrowserContext, type Page, type Route, webkit } from 'playwright';
const execAsync = promisify(exec); const execAsync = promisify(exec);
import * as chrome from './chrome.js';
import * as firefox from './firefox.js'; import * as firefox from './firefox.js';
import * as image from './image.js'; import * as image from './image.js';
import * as safari from './safari.js'; import * as safari from './safari.js';
@@ -21,6 +22,7 @@ import type {
MetricsData, MetricsData,
NetworkEntry, NetworkEntry,
PageError, PageError,
PreviewCommand,
StorageCommand, StorageCommand,
} from './types.js'; } from './types.js';
@@ -394,7 +396,12 @@ export class ClaudeBrowser {
async goto(url: string): Promise<{ url: string; title: string }> { async goto(url: string): Promise<{ url: string; title: string }> {
const page = this.ensurePage(); const page = this.ensurePage();
await page.goto(url, { waitUntil: 'networkidle' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
// Best-effort wait for network to settle — SPAs with persistent connections
// (LinkedIn, Twitter, Gmail) never reach networkidle, so cap at 5s
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(
() => {}
);
return { url: page.url(), title: await page.title() }; return { url: page.url(), title: await page.title() };
} }
@@ -402,7 +409,9 @@ export class ClaudeBrowser {
const page = this.ensurePage(); const page = this.ensurePage();
await this.previewAction(selector, 'CLICK'); await this.previewAction(selector, 'CLICK');
await page.click(selector); await page.click(selector);
await page.waitForLoadState('networkidle').catch(() => {}); await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(
() => {}
);
return { url: page.url() }; return { url: page.url() };
} }
@@ -871,6 +880,62 @@ export class ClaudeBrowser {
} }
} }
private async handlePreviewCommand(cmd: PreviewCommand): Promise<CommandResponse> {
const page = this.ensurePage();
// Resize viewport if dimensions specified
if (cmd.width || cmd.height) {
const current = page.viewportSize();
const width = cmd.width || current?.width || 1280;
const height = cmd.height || current?.height || 800;
await page.setViewportSize({ width, height });
}
// Navigate
const nav = await this.goto(cmd.url);
// Screenshot
const outputPath = resolve(cmd.output || '/tmp/preview.png');
await page.screenshot({ path: outputPath, fullPage: cmd.fullPage || false });
// Optional: POST to preview endpoint
let posted = false;
if (cmd.previewUrl) {
posted = await this.postPreview(outputPath, cmd.previewUrl, cmd.title, cmd.caption);
}
return { ok: true, path: outputPath, url: nav.url, title: nav.title, posted };
}
/**
* POST a screenshot to a preview endpoint.
* Payload: { source: "file:///path", title, caption }
* Silent failure — returns false if endpoint is unreachable.
*/
private async postPreview(
imagePath: string,
previewUrl: string,
title?: string,
caption?: string
): Promise<boolean> {
try {
const payload = JSON.stringify({
source: `file://${resolve(imagePath)}`,
title: title || null,
caption: caption || null,
});
const res = await fetch(previewUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
signal: AbortSignal.timeout(3000),
});
return res.ok;
} catch {
return false;
}
}
private async handleImportCommand(cmd: ImportCommand): Promise<CommandResponse> { private async handleImportCommand(cmd: ImportCommand): Promise<CommandResponse> {
const context = this.getContext(); const context = this.getContext();
if (!context) throw new Error('Browser not launched'); if (!context) throw new Error('Browser not launched');
@@ -933,6 +998,34 @@ export class ClaudeBrowser {
}; };
} }
if (cmd.source === 'chrome') {
const cookies = chrome.importChromeCookies({
domain: cmd.domain,
profile: cmd.profile,
});
if (cookies.length === 0) {
return {
ok: true,
imported: 0,
source: 'chrome',
domains: [],
};
}
const playwrightCookies = cookies.map(chrome.toPlaywrightCookie);
await context.addCookies(playwrightCookies);
const domains = [...new Set(cookies.map((c) => c.domain))];
return {
ok: true,
imported: cookies.length,
source: 'chrome',
domains,
};
}
return { ok: false, error: `Unknown import source: ${cmd.source}` }; return { ok: false, error: `Unknown import source: ${cmd.source}` };
} }
@@ -1132,6 +1225,8 @@ export class ClaudeBrowser {
} }
case 'import': case 'import':
return this.handleImportCommand(cmd); return this.handleImportCommand(cmd);
case 'preview':
return this.handlePreviewCommand(cmd);
default: { default: {
const _exhaustive: never = cmd; const _exhaustive: never = cmd;
return { ok: false, error: `Unknown command: ${(_exhaustive as { cmd: string }).cmd}` }; return { ok: false, error: `Unknown command: ${(_exhaustive as { cmd: string }).cmd}` };
+270
View File
@@ -0,0 +1,270 @@
/**
* Chrome cookie importer (macOS)
*
* Reads cookies from Chrome's SQLite database and decrypts values using
* the encryption key stored in the macOS Keychain ("Chrome Safe Storage").
*
* Encryption scheme (macOS):
* - Keychain password → PBKDF2(password, salt="saltysalt", iterations=1003, keylen=16)
* - AES-128-CBC with IV = 16 bytes of 0x20 (space)
* - Encrypted values prefixed with "v10" (3 bytes)
*
* Chrome timestamps are microseconds since Jan 1, 1601 (Windows/WebKit epoch).
* Database is copied to a temp directory to avoid WAL lock conflicts.
*/
import { execSync } from 'node:child_process';
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { homedir, platform, tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
export interface ChromeCookie {
name: string;
value: string;
domain: string;
path: string;
expires: number; // Unix timestamp (seconds)
secure: boolean;
httpOnly: boolean;
sameSite: 'None' | 'Lax' | 'Strict';
}
// Chrome epoch (Jan 1, 1601) to Unix epoch (Jan 1, 1970) offset in microseconds
const CHROME_EPOCH_OFFSET = 11644473600000000n;
/**
* Convert Chrome timestamp (microseconds since Jan 1, 1601) to Unix seconds
*/
function chromeToUnix(chromeTime: bigint): number {
if (chromeTime === 0n) return 0;
const unixMicro = chromeTime - CHROME_EPOCH_OFFSET;
return Number(unixMicro / 1000000n);
}
/**
* Get Chrome encryption key from macOS Keychain, derive AES key via PBKDF2
*/
function getDerivedKey(): Buffer {
if (platform() !== 'darwin') {
throw new Error('Chrome cookie decryption is currently only supported on macOS');
}
const keychainPassword = execSync('security find-generic-password -s "Chrome Safe Storage" -w', {
encoding: 'utf-8',
}).trim();
return pbkdf2Sync(keychainPassword, 'saltysalt', 1003, 16, 'sha1');
}
/**
* Decrypt a Chrome encrypted cookie value
*/
function decryptValue(encrypted: Buffer, derivedKey: Buffer): string {
if (encrypted.length === 0) return '';
// Check for "v10" prefix (macOS encryption marker)
const prefix = encrypted.subarray(0, 3).toString('ascii');
if (prefix !== 'v10') {
// Not encrypted or unknown format — return as-is
return encrypted.toString('utf-8');
}
const ciphertext = encrypted.subarray(3);
if (ciphertext.length === 0) return '';
// AES-128-CBC, IV is 16 bytes of 0x20 (space)
const iv = Buffer.alloc(16, 0x20);
const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv);
try {
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString('utf-8');
} catch {
return '';
}
}
/**
* Get the Chrome user data directory for the current platform
*/
function getChromeRoot(): string {
const home = homedir();
switch (platform()) {
case 'darwin':
return join(home, 'Library/Application Support/Google/Chrome');
case 'linux':
return join(home, '.config/google-chrome');
case 'win32':
return join(
process.env.LOCALAPPDATA || join(home, 'AppData/Local'),
'Google/Chrome/User Data'
);
default:
throw new Error(`Unsupported platform: ${platform()}`);
}
}
/**
* List available Chrome profiles
*/
export function listChromeProfiles(): { name: string; path: string }[] {
const root = getChromeRoot();
if (!existsSync(root)) return [];
const profiles: { name: string; path: string }[] = [];
// Check "Default" profile
const defaultCookies = join(root, 'Default', 'Cookies');
if (existsSync(defaultCookies)) {
profiles.push({ name: 'Default', path: 'Default' });
}
// Check numbered profiles (Profile 1, Profile 2, ...)
try {
const entries = readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('Profile ')) {
const cookiesPath = join(root, entry.name, 'Cookies');
if (existsSync(cookiesPath)) {
profiles.push({ name: entry.name, path: entry.name });
}
}
}
} catch {
// Ignore readdir errors
}
return profiles;
}
/**
* Safely copy the Chrome cookies database to a temp directory.
* Copies Cookies + WAL + SHM files to avoid lock conflicts.
*/
function copyDatabaseSafely(dbPath: string): { tmpDir: string; tmpDbPath: string } {
if (!existsSync(dbPath)) {
throw new Error(
`Chrome cookies database not found at: ${dbPath}\nMake sure Chrome has been used at least once.`
);
}
const tmpDir = mkdtempSync(join(tmpdir(), 'browse-cr-'));
const dbName = 'Cookies';
try {
copyFileSync(dbPath, join(tmpDir, dbName));
for (const ext of ['-wal', '-shm']) {
const src = `${dbPath}${ext}`;
if (existsSync(src)) {
copyFileSync(src, join(tmpDir, `${dbName}${ext}`));
}
}
return { tmpDir, tmpDbPath: join(tmpDir, dbName) };
} catch (err) {
rmSync(tmpDir, { recursive: true, force: true });
throw err;
}
}
/**
* Convert Chrome sameSite integer to string
* -1 = unspecified (treat as None), 0 = None (was "no_restriction"), 1 = Lax, 2 = Strict
*/
function sameSiteToString(value: number): 'None' | 'Lax' | 'Strict' {
switch (value) {
case 2:
return 'Strict';
case 1:
return 'Lax';
default:
return 'None';
}
}
/**
* Import cookies from Chrome's Cookies SQLite database
*/
export function importChromeCookies(options?: {
profile?: string;
domain?: string;
}): ChromeCookie[] {
const root = getChromeRoot();
const profileDir = options?.profile || 'Default';
const dbPath = join(root, profileDir, 'Cookies');
const { tmpDir, tmpDbPath } = copyDatabaseSafely(dbPath);
try {
const derivedKey = getDerivedKey();
const db = new DatabaseSync(tmpDbPath, { readOnly: true });
let query =
'SELECT name, value, encrypted_value, host_key, path, CAST(expires_utc AS TEXT) as expires_utc_str, is_secure, is_httponly, samesite FROM cookies';
const params: string[] = [];
if (options?.domain) {
const domain = options.domain.toLowerCase();
query += ' WHERE LOWER(host_key) = ? OR LOWER(host_key) = ? OR LOWER(host_key) LIKE ?';
params.push(domain, `.${domain}`, `%.${domain}`);
}
const stmt = db.prepare(query);
const rows = params.length > 0 ? stmt.all(...params) : stmt.all();
db.close();
return (rows as Record<string, unknown>[]).map((row) => {
// Use plaintext value if available, otherwise decrypt
let value = row.value as string;
if (!value && row.encrypted_value) {
value = decryptValue(row.encrypted_value as Buffer, derivedKey);
}
return {
name: row.name as string,
value,
domain: row.host_key as string,
path: row.path as string,
expires: chromeToUnix(BigInt(row.expires_utc_str as string)),
secure: (row.is_secure as number) === 1,
httpOnly: (row.is_httponly as number) === 1,
sameSite: sameSiteToString(row.samesite as number),
};
});
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
}
/**
* Convert ChromeCookie to Playwright cookie format
*/
export function toPlaywrightCookie(cookie: ChromeCookie): {
name: string;
value: string;
domain: string;
path: string;
expires: number;
secure: boolean;
httpOnly: boolean;
sameSite: 'Strict' | 'Lax' | 'None';
} {
let expires = cookie.expires;
if (expires <= 0) {
expires = -1;
}
return {
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
expires,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite,
};
}
+13
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util'; import { parseArgs } from 'node:util';
import { ClaudeBrowser } from './browser.js'; import { ClaudeBrowser } from './browser.js';
import * as image from './image.js'; import * as image from './image.js';
import { startServer } from './server.js';
import type { ElementInfo } from './types.js'; import type { ElementInfo } from './types.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -27,6 +28,7 @@ const { values, positionals } = parseArgs({
json: { type: 'boolean', short: 'j', default: false }, json: { type: 'boolean', short: 'j', default: false },
click: { type: 'string', short: 'c', multiple: true }, click: { type: 'string', short: 'c', multiple: true },
type: { type: 'string', short: 't', multiple: true }, type: { type: 'string', short: 't', multiple: true },
server: { type: 'string', short: 's' },
help: { type: 'boolean', default: false }, help: { type: 'boolean', default: false },
version: { type: 'boolean', short: 'v', default: false }, version: { type: 'boolean', short: 'v', default: false },
// Image processing options // Image processing options
@@ -55,6 +57,7 @@ Options:
-j, --json Output query results as JSON -j, --json Output query results as JSON
-c, --click <selector> Click on element (can be repeated for multiple clicks) -c, --click <selector> Click on element (can be repeated for multiple clicks)
-t, --type <sel>=<text> Type text into input (can be repeated) -t, --type <sel>=<text> Type text into input (can be repeated)
-s, --server <port> Start HTTP server mode (default port: 13373)
-v, --version Show version -v, --version Show version
--help Show this help --help Show this help
@@ -82,6 +85,10 @@ Image processing examples:
browse https://example.com --resize 800x600 browse https://example.com --resize 800x600
browse https://example.com --compress 60 browse https://example.com --compress 60
Server mode:
browse -s 13373 # Start HTTP server on port 13373
curl localhost:13373 -d '{"cmd":"goto","url":"https://example.com"}'
MCP Server (for Claude Code integration): MCP Server (for Claude Code integration):
browse-mcp # Run as MCP server (stdio transport) browse-mcp # Run as MCP server (stdio transport)
`; `;
@@ -246,6 +253,12 @@ async function main(): Promise<void> {
process.exit(0); process.exit(0);
} }
if (values.server !== undefined) {
const port = Number.parseInt(values.server as string) || 13373;
await startServer({ port });
return;
}
if (positionals.length === 0) { if (positionals.length === 0) {
console.log(HELP); console.log(HELP);
process.exit(0); process.exit(0);
+249
View File
@@ -0,0 +1,249 @@
/**
* Browse configuration and session persistence
*
* Config: ~/.config/browse/config.json — user defaults for launch options
* Session: ~/.config/browse/session.json — persistent cookies, storage, last URL
*
* Config is loaded once at startup and merged under explicit tool args.
* Session is auto-saved on close and auto-restored on launch (if present).
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
const CONFIG_DIR = join(homedir(), '.config', 'browse');
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
const SESSION_PATH = join(CONFIG_DIR, 'session.json');
// ── Config ──────────────────────────────────────────────────
export interface BrowseConfig {
/** Launch headless (default: true) */
headless?: boolean;
/** Default viewport width (default: 1280) */
width?: number;
/** Default viewport height (default: 800) */
height?: number;
/** Launch in macOS native fullscreen (default: false) */
fullscreen?: boolean;
/** Enable preview mode — highlight elements before actions (default: false) */
preview?: boolean;
/** Preview highlight duration in ms (default: 2000) */
previewDelay?: number;
/** Enable stealth mode to reduce bot detection (default: false) */
stealth?: boolean;
/** Auto-restore session on launch (default: true) */
autoRestore?: boolean;
/** Auto-save session on close (default: true) */
autoSave?: boolean;
/** Default browser to import cookies from on first launch */
importFrom?: 'safari' | 'firefox' | 'chrome';
/** Default domain filter for cookie import */
importDomain?: string;
}
const DEFAULTS: Required<BrowseConfig> = {
headless: false,
width: 1280,
height: 800,
fullscreen: true,
preview: true,
previewDelay: 2000,
stealth: true,
autoRestore: true,
autoSave: true,
importFrom: 'safari',
importDomain: '',
};
function ensureDir(): void {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true });
}
}
/**
* Load config from ~/.config/browse/config.json
* Returns defaults merged with user config. Missing file = all defaults.
*/
export function loadConfig(): Required<BrowseConfig> {
if (!existsSync(CONFIG_PATH)) return { ...DEFAULTS };
try {
const raw = readFileSync(CONFIG_PATH, 'utf-8');
const user = JSON.parse(raw) as Partial<BrowseConfig>;
return { ...DEFAULTS, ...user };
} catch {
return { ...DEFAULTS };
}
}
/**
* Save config to ~/.config/browse/config.json
*/
export function saveConfig(config: Partial<BrowseConfig>): void {
ensureDir();
writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`);
}
/**
* Get the config file path (for display/debugging)
*/
export function getConfigPath(): string {
return CONFIG_PATH;
}
// ── Session ─────────────────────────────────────────────────
export interface BrowseSession {
/** Last visited URL */
url?: string;
/** Page title at save time */
title?: string;
/** All cookies from the browser context */
cookies?: Array<{
name: string;
value: string;
domain: string;
path: string;
expires: number;
secure: boolean;
httpOnly: boolean;
sameSite: 'Strict' | 'Lax' | 'None';
}>;
/** localStorage key-value pairs (per origin) */
localStorage?: Record<string, string>;
/** sessionStorage key-value pairs (per origin) */
sessionStorage?: Record<string, string>;
/** ISO timestamp of last save */
savedAt?: string;
}
/**
* Load session from ~/.config/browse/session.json
* Returns null if no session file exists.
*/
export function loadSession(): BrowseSession | null {
if (!existsSync(SESSION_PATH)) return null;
try {
const raw = readFileSync(SESSION_PATH, 'utf-8');
return JSON.parse(raw) as BrowseSession;
} catch {
return null;
}
}
/**
* Save session to ~/.config/browse/session.json
*/
export function saveSession(session: BrowseSession): void {
ensureDir();
session.savedAt = new Date().toISOString();
writeFileSync(SESSION_PATH, `${JSON.stringify(session, null, 2)}\n`);
}
/**
* Delete the session file
*/
export function clearSession(): void {
if (existsSync(SESSION_PATH)) {
const { unlinkSync } = require('node:fs');
unlinkSync(SESSION_PATH);
}
}
/**
* Get the session file path (for display/debugging)
*/
export function getSessionPath(): string {
return SESSION_PATH;
}
// ── Import All ──────────────────────────────────────────────
type PlaywrightCookie = {
name: string;
value: string;
domain: string;
path: string;
expires: number;
secure: boolean;
httpOnly: boolean;
sameSite: 'Strict' | 'Lax' | 'None';
};
/**
* Import cookies from all available browsers, deduplicate, and save to session.json.
* Dedup key: domain + name + path. Last-write wins (Chrome > Firefox > Safari priority).
* Returns the merged cookie count.
*/
export async function importAllToSession(): Promise<{
total: number;
sources: Record<string, number>;
}> {
const all: PlaywrightCookie[] = [];
const sources: Record<string, number> = {};
// Safari (async — binary parser)
try {
const { importSafariCookies, toPlaywrightCookie } = await import('./safari.js');
const cookies = await importSafariCookies();
const converted = cookies.map(toPlaywrightCookie);
all.push(...converted);
sources.safari = cookies.length;
} catch {
sources.safari = 0;
}
// Firefox (sync — SQLite)
try {
const { importFirefoxCookies, listFirefoxProfiles, toPlaywrightCookie } = await import(
'./firefox.js'
);
// Try default-release first (main profile on macOS), fall back to default
const profiles = listFirefoxProfiles();
const profile =
profiles.find((p) => p.name === 'default-release') ||
profiles.find((p) => p.isDefault) ||
profiles[0];
if (profile) {
const cookies = importFirefoxCookies({ profile: profile.name });
const converted = cookies.map(toPlaywrightCookie);
all.push(...converted);
sources.firefox = cookies.length;
} else {
sources.firefox = 0;
}
} catch {
sources.firefox = 0;
}
// Chrome (sync — SQLite + Keychain decryption, macOS only)
try {
const { importChromeCookies, toPlaywrightCookie } = await import('./chrome.js');
const cookies = importChromeCookies();
const converted = cookies.map(toPlaywrightCookie);
all.push(...converted);
sources.chrome = cookies.length;
} catch {
sources.chrome = 0;
}
// Deduplicate: last-write wins (Chrome overwrites Firefox overwrites Safari)
const seen = new Map<string, PlaywrightCookie>();
for (const cookie of all) {
const key = `${cookie.domain}|${cookie.name}|${cookie.path}`;
seen.set(key, cookie);
}
const deduped = [...seen.values()];
// Save to session.json
saveSession({
cookies: deduped,
});
return { total: deduped.length, sources };
}
+20 -2
View File
@@ -272,12 +272,30 @@ export function toPlaywrightCookie(cookie: FirefoxCookie): {
httpOnly: boolean; httpOnly: boolean;
sameSite: 'Strict' | 'Lax' | 'None'; sameSite: 'Strict' | 'Lax' | 'None';
} { } {
// Firefox uses 0 for session cookies; Playwright requires -1 or positive unix timestamp (seconds).
// Some Firefox cookies store expiry in milliseconds instead of seconds — detect and convert.
// Any expiry > year 2100 in seconds (4102444800) is likely milliseconds.
let expires = cookie.expires;
if (expires > 4102444800) {
expires = Math.floor(expires / 1000);
}
if (expires <= 0) {
expires = -1;
}
// Normalize domain: Firefox sometimes stores ".www.example.com" which Playwright
// won't match for "www.example.com". Strip ".www." prefix to ".example.com".
let domain = cookie.domain;
if (domain.startsWith('.www.')) {
domain = domain.slice(4); // ".www.example.com" -> ".example.com"
}
return { return {
name: cookie.name, name: cookie.name,
value: cookie.value, value: cookie.value,
domain: cookie.domain, domain,
path: cookie.path, path: cookie.path,
expires: cookie.expires, expires,
secure: cookie.secure, secure: cookie.secure,
httpOnly: cookie.httpOnly, httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite, sameSite: cookie.sameSite,
+14
View File
@@ -1,4 +1,18 @@
export { ClaudeBrowser } from './browser.js'; export { ClaudeBrowser } from './browser.js';
export { importChromeCookies, listChromeProfiles, type ChromeCookie } from './chrome.js';
export {
loadConfig,
saveConfig,
loadSession,
saveSession,
clearSession,
importAllToSession,
getConfigPath,
getSessionPath,
type BrowseConfig,
type BrowseSession,
} from './config.js';
export { BrowserServer, startServer, type ServerOptions } from './server.js';
export { export {
createFavicon, createFavicon,
convert, convert,
+150 -11
View File
@@ -6,21 +6,31 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod'; import { z } from 'zod';
import { ClaudeBrowser } from './browser.js'; import { ClaudeBrowser } from './browser.js';
import {
getConfigPath,
getSessionPath,
importAllToSession,
loadConfig,
loadSession,
saveSession,
} from './config.js';
import * as image from './image.js'; import * as image from './image.js';
import { type CommandLike, type ResultLike, stderrLogger as log } from './logger.js'; import { type CommandLike, type ResultLike, stderrLogger as log } from './logger.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8')); const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
// Browser options configurable via launch tool // Load user config from ~/.config/browse/config.json
const userConfig = loadConfig();
let browserOptions = { let browserOptions = {
headless: true, headless: userConfig.headless,
width: 1280, width: userConfig.width,
height: 800, height: userConfig.height,
fullscreen: false, fullscreen: userConfig.fullscreen,
preview: false, preview: userConfig.preview,
previewDelay: 2000, previewDelay: userConfig.previewDelay,
stealth: false, stealth: userConfig.stealth,
}; };
let browser = new ClaudeBrowser(browserOptions); let browser = new ClaudeBrowser(browserOptions);
let launched = false; let launched = false;
@@ -30,6 +40,59 @@ async function ensureLaunched(): Promise<void> {
if (!launched) { if (!launched) {
await browser.launch(); await browser.launch();
launched = true; launched = true;
// Auto-restore session if enabled
// If no session.json exists, import all browser cookies first
if (userConfig.autoRestore) {
let session = loadSession();
if (!session) {
try {
const result = await importAllToSession();
log.command({
cmd: 'auto_import',
url: `${result.total} cookies from ${Object.entries(result.sources)
.filter(([, v]) => v > 0)
.map(([k, v]) => `${k}:${v}`)
.join(', ')}`,
});
session = loadSession();
} catch (err) {
log.result({ cmd: 'auto_import' }, { ok: false, error: (err as Error).message });
}
}
if (session) {
try {
const context = browser.getContext();
if (context && session.cookies?.length) {
await context.addCookies(session.cookies);
}
const page = browser.getPage();
if (page && session.url && session.url !== 'about:blank') {
await page.goto(session.url, { waitUntil: 'domcontentloaded' });
await Promise.race([
page.waitForLoadState('networkidle'),
page.waitForTimeout(5000),
]).catch(() => {});
// Restore localStorage/sessionStorage after navigation
if (session.localStorage || session.sessionStorage) {
const local = session.localStorage || {};
const sessionStorage = session.sessionStorage || {};
await page.evaluate(
`((data) => {
for (const [k, v] of Object.entries(data.local)) localStorage.setItem(k, v);
for (const [k, v] of Object.entries(data.session)) sessionStorage.setItem(k, v);
})({ local: ${JSON.stringify(local)}, session: ${JSON.stringify(sessionStorage)} })`
);
}
}
log.command({ cmd: 'auto_restore', url: session.url });
} catch (err) {
log.result({ cmd: 'auto_restore' }, { ok: false, error: (err as Error).message });
}
}
}
} }
} }
@@ -255,6 +318,40 @@ server.tool(
}) })
); );
// Preview — navigate + screenshot in one call, optional POST to preview endpoint
server.tool(
'preview',
'Navigate to a URL and take a screenshot in one call. Optionally POST the result to a preview endpoint.',
{
url: z.string().describe('URL or file:///path to preview'),
width: z.number().optional().default(1280).describe('Viewport width'),
height: z.number().optional().default(800).describe('Viewport height'),
fullPage: z.boolean().optional().default(false),
output: z.string().optional().default('/tmp/preview.png').describe('Screenshot output path'),
previewUrl: z.string().optional().describe('HTTP endpoint to POST screenshot result to'),
title: z.string().optional().describe('Title sent with preview POST'),
caption: z.string().optional().describe('Caption sent with preview POST'),
},
withLogging(
'preview',
async ({ url, width, height, fullPage, output, previewUrl, title, caption }) => {
await ensureLaunched();
const result = await browser.executeCommand({
cmd: 'preview',
url,
width,
height,
fullPage,
output,
previewUrl,
title,
caption,
});
return textResult(JSON.stringify(result));
}
)
);
// Eval // Eval
server.tool( server.tool(
'eval', 'eval',
@@ -579,6 +676,45 @@ server.tool(
{}, {},
withLogging('close', async () => { withLogging('close', async () => {
if (launched) { if (launched) {
// Auto-save session before closing
if (userConfig.autoSave) {
try {
const page = browser.getPage();
const context = browser.getContext();
if (page && context) {
const url = page.url();
const title = await page.title();
const cookies = await context.cookies();
const storage = (await page.evaluate(`({
localStorage: Object.fromEntries(
Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i))
.filter(k => k !== null)
.map(k => [k, localStorage.getItem(k) || ''])
),
sessionStorage: Object.fromEntries(
Array.from({ length: sessionStorage.length }, (_, i) => sessionStorage.key(i))
.filter(k => k !== null)
.map(k => [k, sessionStorage.getItem(k) || ''])
),
})`)) as {
localStorage: Record<string, string>;
sessionStorage: Record<string, string>;
};
saveSession({
url,
title,
cookies,
localStorage: storage.localStorage,
sessionStorage: storage.sessionStorage,
});
log.command({ cmd: 'auto_save', url });
}
} catch (err) {
log.result({ cmd: 'auto_save' }, { ok: false, error: (err as Error).message });
}
}
await browser.close(); await browser.close();
launched = false; launched = false;
} }
@@ -665,7 +801,10 @@ server.tool(
// Navigate to saved URL // Navigate to saved URL
if (data.url) { if (data.url) {
await page.goto(data.url, { waitUntil: 'networkidle' }); await page.goto(data.url, { waitUntil: 'domcontentloaded' });
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(5000)]).catch(
() => {}
);
} }
// Restore storage (runs in browser context) // Restore storage (runs in browser context)
@@ -693,9 +832,9 @@ server.tool(
// Browser import // Browser import
server.tool( server.tool(
'import', 'import',
'Import cookies from Safari or Firefox browser. Safari requires Full Disk Access permission (macOS only). Firefox works on macOS, Linux, and Windows.', 'Import cookies from Safari, Firefox, or Chrome browser. Safari requires Full Disk Access (macOS). Chrome requires Keychain access (macOS). Firefox works on all platforms.',
{ {
source: z.enum(['safari', 'firefox']).describe('Browser to import from'), source: z.enum(['safari', 'firefox', 'chrome']).describe('Browser to import from'),
domain: z domain: z
.string() .string()
.optional() .optional()
+143
View File
@@ -0,0 +1,143 @@
import { type IncomingMessage, type Server, type ServerResponse, createServer } from 'node:http';
import chalk from 'chalk';
import logSymbols from 'log-symbols';
import { ClaudeBrowser } from './browser.js';
import { logger, ts } from './logger.js';
import type { BrowserCommand, BrowserOptions } from './types.js';
export interface ServerOptions extends BrowserOptions {
port?: number;
}
const CORS_HEADERS: Record<string, string> = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
function printBanner(port: number): void {
console.log();
console.log(chalk.cyan.bold(' browse server'));
console.log(chalk.dim(' ─────────────────────────'));
console.log(` ${logSymbols.success} Listening on ${chalk.bold(`http://localhost:${port}`)}`);
console.log();
console.log(chalk.dim(' Commands:'));
console.log(
` ${chalk.cyan('goto')} ${chalk.yellow('click')} ${chalk.magenta('type')} ${chalk.blue('query')} ${chalk.green('screenshot')}`
);
console.log(
` ${chalk.cyan('url')} ${chalk.blue('html')} ${chalk.yellow('back')} ${chalk.yellow('forward')} ${chalk.yellow('reload')} ${chalk.gray('wait')} ${chalk.red('close')}`
);
console.log();
console.log(chalk.dim(' Example:'));
console.log(
chalk.gray(` curl localhost:${port} -d '{"cmd":"goto","url":"https://example.com"}'`)
);
console.log();
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
req.on('error', reject);
});
}
function sendJson(res: ServerResponse, status: number, data: unknown): void {
const body = JSON.stringify(data);
res.writeHead(status, { ...CORS_HEADERS, 'Content-Type': 'application/json' });
res.end(body);
}
export class BrowserServer {
private browser: ClaudeBrowser;
private server: Server | null = null;
private port: number;
constructor(options: ServerOptions = {}) {
this.browser = new ClaudeBrowser(options);
this.port = options.port ?? 13373;
}
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.method === 'OPTIONS') {
res.writeHead(204, CORS_HEADERS);
res.end();
return;
}
if (req.method !== 'POST' || req.url !== '/') {
sendJson(res, 404, { ok: false, error: 'POST / only' });
return;
}
try {
const raw = await readBody(req);
const cmd: BrowserCommand = JSON.parse(raw);
logger.command(cmd);
if (cmd.cmd === 'close') {
logger.result(cmd, { ok: true });
sendJson(res, 200, { ok: true });
await this.stop();
process.exit(0);
}
const result = await this.browser.executeCommand(cmd);
logger.result(cmd, result);
// For screenshot without a path, include base64 data
if (cmd.cmd === 'screenshot' && !cmd.path && result.ok) {
const page = this.browser.getPage();
if (page) {
const buffer = await page.screenshot();
(result as unknown as Record<string, unknown>).data = buffer.toString('base64');
}
}
sendJson(res, 200, result);
} catch (err) {
const error = (err as Error).message;
console.log(`${ts()} ${logSymbols.error} ${chalk.red(error)}`);
sendJson(res, 500, { ok: false, error });
}
}
async start(): Promise<void> {
await this.browser.launch();
return new Promise((resolve) => {
this.server = createServer((req, res) => {
this.handleRequest(req, res).catch((err) => {
sendJson(res, 500, { ok: false, error: (err as Error).message });
});
});
this.server.listen(this.port, () => {
printBanner(this.port);
resolve();
});
});
}
async stop(): Promise<void> {
console.log(chalk.dim('\n Shutting down...'));
if (this.server) {
this.server.close();
this.server = null;
}
await this.browser.close();
console.log(` ${logSymbols.success} Browser closed\n`);
}
getPort(): number {
return this.port;
}
}
export async function startServer(options: ServerOptions = {}): Promise<BrowserServer> {
const server = new BrowserServer(options);
await server.start();
return server;
}
+18 -3
View File
@@ -302,10 +302,10 @@ export interface EmulateCommand {
device: string; device: string;
} }
// Browser import (Safari, Firefox) // Browser import (Safari, Firefox, Chrome)
export interface ImportCommand { export interface ImportCommand {
cmd: 'import'; cmd: 'import';
source: 'safari' | 'firefox'; source: 'safari' | 'firefox' | 'chrome';
domain?: string; domain?: string;
profile?: string; profile?: string;
} }
@@ -347,7 +347,20 @@ export type BrowserCommand =
| ScrollCommand | ScrollCommand
| ViewportCommand | ViewportCommand
| EmulateCommand | EmulateCommand
| ImportCommand; | ImportCommand
| PreviewCommand;
export interface PreviewCommand {
cmd: 'preview';
url: string;
width?: number;
height?: number;
fullPage?: boolean;
output?: string;
previewUrl?: string;
title?: string;
caption?: string;
}
// Response types // Response types
export interface SuccessResponse { export interface SuccessResponse {
@@ -359,6 +372,8 @@ export interface SuccessResponse {
count?: number; count?: number;
elements?: ElementInfo[]; elements?: ElementInfo[];
result?: unknown; result?: unknown;
// Preview fields
posted?: boolean;
// Image processing fields // Image processing fields
files?: string[]; files?: string[];
outputDir?: string; outputDir?: string;