@mehmoodqureshi/chrome-mcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,30 @@ By default everything is **deny-all** (no domains, no eval, no mutations). Grant
34
34
  exactly what you need with `--allow-domain <glob>` (repeatable), `--enable-mutations`,
35
35
  `--enable-downloads`, `--unsafe-enable-eval`, or `--unsafe-all-domains`.
36
36
 
37
+ **Drive only your real Chrome (recommended for the extension).** Add
38
+ `--no-cdp-fallback` so the server never launches a separate Chromium, and
39
+ `--persist-token` so the pairing token survives restarts — **pair once, never
40
+ again**:
41
+
42
+ ```jsonc
43
+ {
44
+ "mcpServers": {
45
+ "chrome-mcp": {
46
+ "command": "npx",
47
+ "args": ["-y", "@mehmoodqureshi/chrome-mcp",
48
+ "--allow-domain", "example.com", "--enable-mutations",
49
+ "--no-cdp-fallback", "--persist-token"]
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Without `--persist-token` a fresh token is minted every boot (the secure
56
+ default), which means re-pairing the extension on each restart. With it, the
57
+ token is stored 0600 at `~/.chrome-mcp/token` and reused; the extension's
58
+ keepalive auto-reconnects with no manual step. `CHROME_MCP_TOKEN` pins the token
59
+ explicitly (and is never written to disk).
60
+
37
61
  **2. Load the extension** (to drive your *real* Chrome): build it, then
38
62
  `chrome://extensions` → enable Developer mode → **Load unpacked** → select
39
63
  `extension-dist/`.
@@ -43,15 +67,24 @@ print its path, open the extension's **Options** page, and paste the `port` +
43
67
  `token` from `~/.chrome-mcp/handshake.json`. (Without the extension, the CLI
44
68
  falls back to a Playwright-driven Chromium automatically.)
45
69
 
46
- The 26 tools cover tabs, navigation, interaction (`click`/`type`/`press`/`hover`/
47
- `scroll`), reads (`get_text`/`get_html`/`screenshot`/`eval`/`wait_for`), helpers
48
- (`extract_links`/`read_as_markdown`/`fill_form`/`download_file`), and `chrome_status`.
70
+ The tools cover tabs, navigation, interaction (`click`/`type`/`press`/`hover`/
71
+ `scroll`/`select_option`), reads (`get_text`/`get_html`/`screenshot`/`eval`/`wait_for`),
72
+ an accessibility `snapshot` (interactive elements with stable `ref`s the model can
73
+ target instead of guessing CSS selectors), session access (`get_cookies`/`storage`),
74
+ helpers (`extract_links`/`read_as_markdown`/`fill_form`/`download_file`), and
75
+ `chrome_status`.
76
+
77
+ `click`/`type` accept `trusted: true` for real OS-level input (works on
78
+ React/Vue controlled inputs); interactions auto-wait for the target to appear.
49
79
 
50
80
  ## Status
51
81
 
52
- v0.1.0 — all six build phases complete and green (50 automated tests + a gated
82
+ v0.2.0 — all six build phases complete and green (57 automated tests + a gated
53
83
  headed extension smoke). End-to-end working: `npx chrome-mcp` ⇄ bridge ⇄
54
- extension ⇄ your real Chrome, with a Playwright CDP fallback.
84
+ extension ⇄ your real Chrome, with a Playwright CDP fallback. v0.2 adds the
85
+ accessibility `snapshot` + element refs, auto-wait, cookies/storage/`select_option`,
86
+ trusted input (`chrome.debugger`), a toolbar status badge, and a stable pairing
87
+ token (`--persist-token`).
55
88
 
56
89
  - [x] **Phase 0 — Contracts & skeleton:** `shared/protocol.ts` (wire contract),
57
90
  `src/executor/types.ts` (Executor interface), `src/security/policy.ts`
@@ -32,7 +32,7 @@ export declare const CLOSE_SUPERSEDED: 4000;
32
32
  * `download_file` (privileged, executor-owned) and `ping_probe` (a short-deadline
33
33
  * responsiveness check used to detect a dead-but-not-yet-reconnected worker).
34
34
  */
35
- export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'eval' | 'wait_for' | 'download_file' | 'ping_probe';
35
+ export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'ping_probe';
36
36
  /** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
37
37
  export declare const WIRE_METHODS: readonly WireMethod[];
38
38
  export type ExecutorErrorCode = 'NO_TARGET' | 'TARGET_GONE' | 'DETACHED' | 'DEVTOOLS_OPEN' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_THREW' | 'TIMEOUT' | 'BAD_ARGS' | 'CDP_ERROR' | 'POLICY_DENIED' | 'DOWNLOAD_FAILED' | 'UNKNOWN_METHOD';
@@ -47,6 +47,10 @@ exports.WIRE_METHODS = [
47
47
  'screenshot',
48
48
  'get_text',
49
49
  'get_html',
50
+ 'snapshot',
51
+ 'select_option',
52
+ 'get_cookies',
53
+ 'storage',
50
54
  'eval',
51
55
  'wait_for',
52
56
  'download_file',
@@ -0,0 +1,26 @@
1
+ /**
2
+ * shared/snapshot.ts — the page-injected accessibility walk, shared VERBATIM by
3
+ * both backends (CDP `page.evaluate` and the extension's `chrome.scripting`).
4
+ *
5
+ * MUST be self-contained: it is serialized to source and runs in the PAGE
6
+ * context, so it may not close over anything from this module. It tags each
7
+ * returned element with a stable `data-mcp-ref` so a later click/type can target
8
+ * it by `ref` (resolved to `[data-mcp-ref="..."]`). Refs live until navigation.
9
+ */
10
+ export interface RawSnapshotNode {
11
+ ref: string;
12
+ role: string;
13
+ name: string;
14
+ tag: string;
15
+ value?: string;
16
+ disabled?: boolean;
17
+ checked?: boolean;
18
+ }
19
+ export interface RawSnapshot {
20
+ url: string;
21
+ title: string;
22
+ nodes: RawSnapshotNode[];
23
+ truncated: boolean;
24
+ }
25
+ /** Runs IN THE PAGE. Returns interactive (and optionally landmark) elements with fresh refs. */
26
+ export declare function collectSnapshot(interactiveOnly?: boolean, max?: number): RawSnapshot;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ /**
3
+ * shared/snapshot.ts — the page-injected accessibility walk, shared VERBATIM by
4
+ * both backends (CDP `page.evaluate` and the extension's `chrome.scripting`).
5
+ *
6
+ * MUST be self-contained: it is serialized to source and runs in the PAGE
7
+ * context, so it may not close over anything from this module. It tags each
8
+ * returned element with a stable `data-mcp-ref` so a later click/type can target
9
+ * it by `ref` (resolved to `[data-mcp-ref="..."]`). Refs live until navigation.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.collectSnapshot = collectSnapshot;
13
+ /** Runs IN THE PAGE. Returns interactive (and optionally landmark) elements with fresh refs. */
14
+ function collectSnapshot(interactiveOnly = true, max = 200) {
15
+ const INTERACTIVE = 'a[href],button,input,select,textarea,[role=button],[role=link],[role=tab],[role=checkbox],[role=radio],[role=menuitem],[role=option],[role=switch],[contenteditable=true],[onclick]';
16
+ const LANDMARK = 'h1,h2,h3,[role=heading],nav,main,header,footer,[role=navigation]';
17
+ const sel = interactiveOnly ? INTERACTIVE : `${INTERACTIVE},${LANDMARK}`;
18
+ const visible = (el) => {
19
+ const r = el.getBoundingClientRect();
20
+ if (r.width === 0 && r.height === 0)
21
+ return false;
22
+ const s = window.getComputedStyle(el);
23
+ return s.visibility !== 'hidden' && s.display !== 'none';
24
+ };
25
+ const accName = (el) => {
26
+ const aria = el.getAttribute('aria-label');
27
+ if (aria)
28
+ return aria.trim();
29
+ const labelledby = el.getAttribute('aria-labelledby');
30
+ if (labelledby) {
31
+ const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.innerText ?? '').join(' ').trim();
32
+ if (t)
33
+ return t;
34
+ }
35
+ const ph = el.getAttribute('placeholder');
36
+ if (ph)
37
+ return ph.trim();
38
+ const title = el.getAttribute('title');
39
+ if (title)
40
+ return title.trim();
41
+ const text = el.innerText ?? '';
42
+ if (text)
43
+ return text.replace(/\s+/g, ' ').trim().slice(0, 120);
44
+ const alt = el.querySelector('img[alt]')?.getAttribute('alt');
45
+ return (alt ?? '').trim();
46
+ };
47
+ const roleOf = (el) => {
48
+ const explicit = el.getAttribute('role');
49
+ if (explicit)
50
+ return explicit;
51
+ const tag = el.tagName.toLowerCase();
52
+ if (tag === 'a')
53
+ return 'link';
54
+ if (tag === 'button')
55
+ return 'button';
56
+ if (tag === 'select')
57
+ return 'combobox';
58
+ if (tag === 'textarea')
59
+ return 'textbox';
60
+ if (tag === 'input') {
61
+ const t = el.type;
62
+ if (t === 'checkbox')
63
+ return 'checkbox';
64
+ if (t === 'radio')
65
+ return 'radio';
66
+ if (t === 'button' || t === 'submit')
67
+ return 'button';
68
+ return 'textbox';
69
+ }
70
+ return tag;
71
+ };
72
+ const els = Array.from(document.querySelectorAll(sel)).filter(visible);
73
+ const nodes = [];
74
+ let n = 0;
75
+ for (const el of els) {
76
+ if (nodes.length >= max)
77
+ break;
78
+ const ref = `e${++n}`;
79
+ el.setAttribute('data-mcp-ref', ref);
80
+ const node = { ref, role: roleOf(el), name: accName(el), tag: el.tagName.toLowerCase() };
81
+ const v = el.value;
82
+ if (typeof v === 'string' && v)
83
+ node.value = v.slice(0, 200);
84
+ if (el.disabled)
85
+ node.disabled = true;
86
+ if (el.checked)
87
+ node.checked = true;
88
+ nodes.push(node);
89
+ }
90
+ return { url: location.href, title: document.title, nodes, truncated: els.length > nodes.length };
91
+ }
92
+ //# sourceMappingURL=snapshot.js.map
@@ -2,7 +2,9 @@
2
2
  * src/bridge/auth.ts — the ONE auth model (every other variant in the design
3
3
  * drafts was deleted on purpose).
4
4
  *
5
- * - Fresh 256-bit token EVERY boot, never persisted across restarts.
5
+ * - Fresh 256-bit token EVERY boot by default, never persisted across restarts.
6
+ * Persistence is OPT-IN (`--persist-token` / `CHROME_MCP_TOKEN`) for hosts
7
+ * that want a stable token so the extension never has to re-pair.
6
8
  * - Written atomically (tmp + rename) to `handshake.json` at mode 0600; the
7
9
  * mode is re-verified after write and we FAIL CLOSED if it can't be set.
8
10
  * - Compared by hashing both sides to SHA-256 and `timingSafeEqual`-ing the
@@ -12,6 +14,26 @@
12
14
  import { type HandshakeFile } from '../../shared/protocol';
13
15
  /** A fresh 256-bit token, base64url. Generated once per server boot. */
14
16
  export declare function generateToken(): string;
17
+ /** Path of the optional persisted-token file (only used when persistence is on). */
18
+ export declare function tokenPath(dir: string): string;
19
+ /**
20
+ * Read the persisted token (trimmed) if present and non-empty; else null.
21
+ * FAILS CLOSED if the file is group/other-readable — same trust boundary as the
22
+ * handshake, so a loose permission is a hard error rather than a silent reuse.
23
+ */
24
+ export declare function readPersistedToken(dir: string): string | null;
25
+ /** Atomically write the persisted token at 0600 and verify the mode (fail closed). */
26
+ export declare function writePersistedToken(dir: string, token: string): string;
27
+ /**
28
+ * Resolve the token for this boot:
29
+ * 1. `CHROME_MCP_TOKEN` env — an explicit pin; used verbatim, never written to disk.
30
+ * 2. `persist` on — reuse the on-disk token (creating + saving one on first run),
31
+ * so the extension stays paired across restarts.
32
+ * 3. otherwise — a fresh per-boot token (the secure default; never persisted).
33
+ */
34
+ export declare function resolveToken(dir: string, opts: {
35
+ persist: boolean;
36
+ }): string;
15
37
  /** Constant-time token compare via fixed-length SHA-256 digests. */
16
38
  export declare function tokensMatch(a: string, b: string): boolean;
17
39
  export interface WriteHandshakeFields {
@@ -3,7 +3,9 @@
3
3
  * src/bridge/auth.ts — the ONE auth model (every other variant in the design
4
4
  * drafts was deleted on purpose).
5
5
  *
6
- * - Fresh 256-bit token EVERY boot, never persisted across restarts.
6
+ * - Fresh 256-bit token EVERY boot by default, never persisted across restarts.
7
+ * Persistence is OPT-IN (`--persist-token` / `CHROME_MCP_TOKEN`) for hosts
8
+ * that want a stable token so the extension never has to re-pair.
7
9
  * - Written atomically (tmp + rename) to `handshake.json` at mode 0600; the
8
10
  * mode is re-verified after write and we FAIL CLOSED if it can't be set.
9
11
  * - Compared by hashing both sides to SHA-256 and `timingSafeEqual`-ing the
@@ -12,12 +14,17 @@
12
14
  */
13
15
  Object.defineProperty(exports, "__esModule", { value: true });
14
16
  exports.generateToken = generateToken;
17
+ exports.tokenPath = tokenPath;
18
+ exports.readPersistedToken = readPersistedToken;
19
+ exports.writePersistedToken = writePersistedToken;
20
+ exports.resolveToken = resolveToken;
15
21
  exports.tokensMatch = tokensMatch;
16
22
  exports.writeHandshake = writeHandshake;
17
23
  exports.readHandshake = readHandshake;
18
24
  exports.removeHandshake = removeHandshake;
19
25
  exports.redactToken = redactToken;
20
26
  const node_fs_1 = require("node:fs");
27
+ const node_path_1 = require("node:path");
21
28
  const node_crypto_1 = require("node:crypto");
22
29
  const protocol_1 = require("../../shared/protocol");
23
30
  const datadir_1 = require("./datadir");
@@ -25,6 +32,66 @@ const datadir_1 = require("./datadir");
25
32
  function generateToken() {
26
33
  return (0, node_crypto_1.randomBytes)(32).toString('base64url');
27
34
  }
35
+ /** Path of the optional persisted-token file (only used when persistence is on). */
36
+ function tokenPath(dir) {
37
+ return (0, node_path_1.join)(dir, 'token');
38
+ }
39
+ /**
40
+ * Read the persisted token (trimmed) if present and non-empty; else null.
41
+ * FAILS CLOSED if the file is group/other-readable — same trust boundary as the
42
+ * handshake, so a loose permission is a hard error rather than a silent reuse.
43
+ */
44
+ function readPersistedToken(dir) {
45
+ const path = tokenPath(dir);
46
+ let raw;
47
+ try {
48
+ raw = (0, node_fs_1.readFileSync)(path, 'utf8');
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ const mode = (0, node_fs_1.statSync)(path).mode & 0o777;
54
+ if ((mode & 0o077) !== 0) {
55
+ throw new Error(`persisted token ${path} is group/other-accessible (mode ${mode.toString(8)}); refusing to reuse it`);
56
+ }
57
+ const token = raw.trim();
58
+ return token.length > 0 ? token : null;
59
+ }
60
+ /** Atomically write the persisted token at 0600 and verify the mode (fail closed). */
61
+ function writePersistedToken(dir, token) {
62
+ const path = tokenPath(dir);
63
+ const tmp = `${path}.tmp.${process.pid}`;
64
+ (0, node_fs_1.writeFileSync)(tmp, token, { mode: 0o600 });
65
+ (0, node_fs_1.chmodSync)(tmp, 0o600);
66
+ (0, node_fs_1.renameSync)(tmp, path);
67
+ (0, node_fs_1.chmodSync)(path, 0o600);
68
+ const mode = (0, node_fs_1.statSync)(path).mode & 0o777;
69
+ if ((mode & 0o077) !== 0) {
70
+ throw new Error(`persisted token ${path} is group/other-accessible (mode ${mode.toString(8)}); refusing to expose it`);
71
+ }
72
+ return path;
73
+ }
74
+ /**
75
+ * Resolve the token for this boot:
76
+ * 1. `CHROME_MCP_TOKEN` env — an explicit pin; used verbatim, never written to disk.
77
+ * 2. `persist` on — reuse the on-disk token (creating + saving one on first run),
78
+ * so the extension stays paired across restarts.
79
+ * 3. otherwise — a fresh per-boot token (the secure default; never persisted).
80
+ */
81
+ function resolveToken(dir, opts) {
82
+ const fromEnv = process.env.CHROME_MCP_TOKEN?.trim();
83
+ if (fromEnv)
84
+ return fromEnv;
85
+ if (opts.persist) {
86
+ const existing = readPersistedToken(dir);
87
+ if (existing)
88
+ return existing;
89
+ const fresh = generateToken();
90
+ writePersistedToken(dir, fresh);
91
+ return fresh;
92
+ }
93
+ return generateToken();
94
+ }
28
95
  /** Constant-time token compare via fixed-length SHA-256 digests. */
29
96
  function tokensMatch(a, b) {
30
97
  const ha = (0, node_crypto_1.createHash)('sha256').update(a, 'utf8').digest();
package/dist/src/cli.js CHANGED
@@ -39,7 +39,7 @@ async function main() {
39
39
  return;
40
40
  }
41
41
  const dataDir = (0, datadir_1.ensureDataDir)(cfg.dataDir);
42
- const token = (0, auth_1.generateToken)();
42
+ const token = (0, auth_1.resolveToken)(dataDir, { persist: cfg.persistToken });
43
43
  const bridge = new server_1.BridgeServer({
44
44
  token,
45
45
  serverVersion: version(),
@@ -50,6 +50,12 @@ async function main() {
50
50
  const port = await bridge.start();
51
51
  const handshakePath = (0, auth_1.writeHandshake)(dataDir, { port, token });
52
52
  (0, server_2.logErr)(`pairing handshake written to ${handshakePath} (mode 0600; token not logged)`);
53
+ if (process.env.CHROME_MCP_TOKEN) {
54
+ (0, server_2.logErr)('token: pinned from CHROME_MCP_TOKEN (stable; pair once, never again).');
55
+ }
56
+ else if (cfg.persistToken) {
57
+ (0, server_2.logErr)('token: persisted across restarts (--persist-token; pair once, never again).');
58
+ }
53
59
  const cleanup = () => {
54
60
  (0, auth_1.removeHandshake)(dataDir);
55
61
  };
@@ -26,6 +26,8 @@ export interface CliConfig {
26
26
  headless: boolean;
27
27
  /** `--print-pairing`: write the handshake and print its path (never the token). */
28
28
  printPairing: boolean;
29
+ /** `--persist-token`: reuse a stable on-disk token so the extension never re-pairs. */
30
+ persistToken: boolean;
29
31
  showHelp: boolean;
30
32
  showVersion: boolean;
31
33
  logLevel: LogLevel;
@@ -40,6 +40,7 @@ function parseArgs(argv) {
40
40
  let prefer = 'extension';
41
41
  let headless = false;
42
42
  let printPairing = false;
43
+ let persistToken = false;
43
44
  let showHelp = false;
44
45
  let showVersion = false;
45
46
  let logLevel = 'info';
@@ -99,6 +100,9 @@ function parseArgs(argv) {
99
100
  case '--print-pairing':
100
101
  printPairing = true;
101
102
  break;
103
+ case '--persist-token':
104
+ persistToken = true;
105
+ break;
102
106
  case '--log-level':
103
107
  logLevel = requireLogLevel(argv[++i]);
104
108
  break;
@@ -117,6 +121,7 @@ function parseArgs(argv) {
117
121
  prefer,
118
122
  headless,
119
123
  printPairing,
124
+ persistToken,
120
125
  showHelp,
121
126
  showVersion,
122
127
  logLevel,
@@ -164,6 +169,9 @@ Connection:
164
169
  --port <n> WebSocket bridge port (default ${protocol_1.DEFAULT_WS_PORT}; 0 = ephemeral)
165
170
  --data-dir <path> Override the data dir (default ~/.chrome-mcp)
166
171
  --print-pairing Write the handshake and print its path, then exit
172
+ --persist-token Reuse a stable on-disk token across restarts so the
173
+ extension never has to re-pair (default: fresh per boot).
174
+ CHROME_MCP_TOKEN env, if set, pins the token explicitly.
167
175
 
168
176
  Backend:
169
177
  --no-cdp-fallback Do not launch/attach Chromium when no extension is paired
@@ -12,7 +12,7 @@
12
12
  * stealth init on the launch path only, and tab resolution. `tabId`s are stamped
13
13
  * `cdp:<sessionId>:<n>` so a handle never mis-routes across a backend switch.
14
14
  */
15
- import { type ActionOk, type BackendKind, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type NavResult, type ScreenshotResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
15
+ import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type NavResult, type ScreenshotResult, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
16
16
  export interface CdpOptions {
17
17
  mode: 'connect' | 'launch';
18
18
  cdpEndpoint?: string;
@@ -69,12 +69,17 @@ export declare class CdpExecutor implements Executor {
69
69
  tabId?: TabId;
70
70
  button?: 'left' | 'right' | 'middle';
71
71
  clickCount?: number;
72
+ trusted?: boolean;
72
73
  }): Promise<ActionOk>;
73
74
  type(t: Target, text: string, opts?: {
74
75
  tabId?: TabId;
75
76
  clear?: boolean;
76
77
  pressEnter?: boolean;
77
78
  keyEvents?: boolean;
79
+ trusted?: boolean;
80
+ }): Promise<ActionOk>;
81
+ selectOption(t: Target, values: string[], opts?: {
82
+ tabId?: TabId;
78
83
  }): Promise<ActionOk>;
79
84
  fill(t: Target, value: string, opts?: {
80
85
  tabId?: TabId;
@@ -106,6 +111,24 @@ export declare class CdpExecutor implements Executor {
106
111
  }): Promise<{
107
112
  html: string;
108
113
  }>;
114
+ snapshot(opts?: {
115
+ tabId?: TabId;
116
+ interactiveOnly?: boolean;
117
+ max?: number;
118
+ }): Promise<SnapshotResult>;
119
+ getCookies(opts?: {
120
+ tabId?: TabId;
121
+ url?: string;
122
+ }): Promise<{
123
+ cookies: CookieItem[];
124
+ }>;
125
+ storage(args: {
126
+ op: StorageOp;
127
+ key?: string;
128
+ value?: string;
129
+ session?: boolean;
130
+ tabId?: TabId;
131
+ }): Promise<StorageResult>;
109
132
  screenshot(opts?: {
110
133
  tabId?: TabId;
111
134
  fullPage?: boolean;
@@ -21,10 +21,15 @@ const node_path_1 = require("node:path");
21
21
  const node_crypto_1 = require("node:crypto");
22
22
  const playwright_1 = require("playwright");
23
23
  const download_1 = require("../../shared/download");
24
+ const snapshot_1 = require("../../shared/snapshot");
24
25
  const types_1 = require("./types");
25
26
  const SINGLETON_FILES = ['SingletonLock', 'SingletonSocket', 'SingletonCookie'];
26
27
  const STEALTH = `Object.defineProperty(navigator,'webdriver',{get:()=>undefined});`;
27
28
  const NON_CONTENT = /^(file|data|devtools|chrome|about):/i;
29
+ /** Minimal CSS attribute-value escape for ref selectors (refs are `e\d+`, but be safe). */
30
+ function cssEscape(s) {
31
+ return s.replace(/["\\]/g, '\\$&');
32
+ }
28
33
  // --- lock recovery helpers (ported) ---------------------------------------
29
34
  function inspectProfileLock(profileDir) {
30
35
  try {
@@ -239,7 +244,11 @@ class CdpExecutor {
239
244
  locator(page, t) {
240
245
  if ('selector' in t && t.selector !== undefined)
241
246
  return page.locator(t.selector).first();
242
- throw new types_1.ExecutorError('SELECTOR_NOT_FOUND', 'the CDP fallback supports selectors, not refs (use the extension backend)');
247
+ if ('ref' in t && t.ref !== undefined) {
248
+ // Refs are minted by snapshot() as data-mcp-ref attributes on the page.
249
+ return page.locator(`[data-mcp-ref="${cssEscape(t.ref)}"]`).first();
250
+ }
251
+ throw new types_1.ExecutorError('SELECTOR_NOT_FOUND', 'provide a selector or a ref from snapshot()');
243
252
  }
244
253
  // -- tabs ---------------------------------------------------------------
245
254
  async tabsList() {
@@ -298,6 +307,7 @@ class CdpExecutor {
298
307
  ok = { ok: true };
299
308
  async click(t, opts) {
300
309
  const p = await this.resolveTab(opts?.tabId);
310
+ // Playwright already drives real (trusted) input, so `trusted` is a no-op here.
301
311
  await this.locator(p, t).click({ button: opts?.button, clickCount: opts?.clickCount });
302
312
  return this.ok;
303
313
  }
@@ -314,6 +324,18 @@ class CdpExecutor {
314
324
  await loc.press('Enter');
315
325
  return this.ok;
316
326
  }
327
+ async selectOption(t, values, opts) {
328
+ const p = await this.resolveTab(opts?.tabId);
329
+ // Try by value, then fall back to visible label.
330
+ const loc = this.locator(p, t);
331
+ try {
332
+ await loc.selectOption(values);
333
+ }
334
+ catch {
335
+ await loc.selectOption(values.map((label) => ({ label })));
336
+ }
337
+ return this.ok;
338
+ }
317
339
  async fill(t, value, opts) {
318
340
  const p = await this.resolveTab(opts?.tabId);
319
341
  await this.locator(p, t).fill(value);
@@ -354,6 +376,55 @@ class CdpExecutor {
354
376
  const html = opts?.outer ? await loc.evaluate((el) => el.outerHTML) : await loc.innerHTML();
355
377
  return { html };
356
378
  }
379
+ async snapshot(opts) {
380
+ const p = await this.resolveTab(opts?.tabId);
381
+ // Inject collectSnapshot's source and run it in the page (it can't close over module scope).
382
+ const raw = await p.evaluate(([fnSrc, interactiveOnly, max]) => {
383
+ // eslint-disable-next-line no-eval
384
+ const fn = (0, eval)(`(${fnSrc})`);
385
+ return fn(interactiveOnly, max);
386
+ }, [snapshot_1.collectSnapshot.toString(), opts?.interactiveOnly ?? true, opts?.max ?? 200]);
387
+ return raw;
388
+ }
389
+ async getCookies(opts) {
390
+ const p = await this.resolveTab(opts?.tabId);
391
+ const url = opts?.url ?? p.url();
392
+ const ctx = await this.getContext();
393
+ const raw = await ctx.cookies(url);
394
+ return {
395
+ cookies: raw.map((c) => ({
396
+ name: c.name, value: c.value, domain: c.domain, path: c.path,
397
+ secure: c.secure, httpOnly: c.httpOnly, expires: c.expires >= 0 ? c.expires : undefined,
398
+ })),
399
+ };
400
+ }
401
+ async storage(args) {
402
+ const p = await this.resolveTab(args.tabId);
403
+ return p.evaluate((a) => {
404
+ const store = a.session ? window.sessionStorage : window.localStorage;
405
+ if (a.op === 'set') {
406
+ store.setItem(String(a.key), String(a.value ?? ''));
407
+ return { ok: true };
408
+ }
409
+ if (a.op === 'remove') {
410
+ store.removeItem(String(a.key));
411
+ return { ok: true };
412
+ }
413
+ if (a.op === 'clear') {
414
+ store.clear();
415
+ return { ok: true };
416
+ }
417
+ if (a.key)
418
+ return { ok: true, value: store.getItem(a.key) };
419
+ const entries = {};
420
+ for (let i = 0; i < store.length; i++) {
421
+ const k = store.key(i);
422
+ if (k)
423
+ entries[k] = store.getItem(k) ?? '';
424
+ }
425
+ return { ok: true, entries };
426
+ }, args);
427
+ }
357
428
  async screenshot(opts) {
358
429
  const p = await this.resolveTab(opts?.tabId);
359
430
  const buf = opts?.target
@@ -7,7 +7,7 @@
7
7
  * method-specific arguments travel in `params`. Results are trusted shapes
8
8
  * produced by the extension router (validated there).
9
9
  */
10
- import { type ActionOk, type BackendKind, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type MouseButton, type NavResult, type ScreenshotResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
10
+ import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type MouseButton, type NavResult, type ScreenshotResult, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
11
11
  import type { BridgeServer } from '../bridge/server';
12
12
  export declare class ExtensionExecutor implements Executor {
13
13
  private readonly bridge;
@@ -40,12 +40,17 @@ export declare class ExtensionExecutor implements Executor {
40
40
  tabId?: TabId;
41
41
  button?: MouseButton;
42
42
  clickCount?: number;
43
+ trusted?: boolean;
43
44
  }): Promise<ActionOk>;
44
45
  type(t: Target, text: string, opts?: {
45
46
  tabId?: TabId;
46
47
  clear?: boolean;
47
48
  pressEnter?: boolean;
48
49
  keyEvents?: boolean;
50
+ trusted?: boolean;
51
+ }): Promise<ActionOk>;
52
+ selectOption(t: Target, values: string[], opts?: {
53
+ tabId?: TabId;
49
54
  }): Promise<ActionOk>;
50
55
  fill(t: Target, value: string, opts?: {
51
56
  tabId?: TabId;
@@ -77,6 +82,24 @@ export declare class ExtensionExecutor implements Executor {
77
82
  }): Promise<{
78
83
  html: string;
79
84
  }>;
85
+ snapshot(opts?: {
86
+ tabId?: TabId;
87
+ interactiveOnly?: boolean;
88
+ max?: number;
89
+ }): Promise<SnapshotResult>;
90
+ getCookies(opts?: {
91
+ tabId?: TabId;
92
+ url?: string;
93
+ }): Promise<{
94
+ cookies: CookieItem[];
95
+ }>;
96
+ storage(args: {
97
+ op: StorageOp;
98
+ key?: string;
99
+ value?: string;
100
+ session?: boolean;
101
+ tabId?: TabId;
102
+ }): Promise<StorageResult>;
80
103
  screenshot(opts?: {
81
104
  tabId?: TabId;
82
105
  fullPage?: boolean;
@@ -81,10 +81,13 @@ class ExtensionExecutor {
81
81
  }
82
82
  // -- interaction --------------------------------------------------------
83
83
  async click(t, opts) {
84
- return (await this.send('click', { ...targetParams(t), button: opts?.button, clickCount: opts?.clickCount }, { tabId: opts?.tabId }));
84
+ return (await this.send('click', { ...targetParams(t), button: opts?.button, clickCount: opts?.clickCount, trusted: opts?.trusted }, { tabId: opts?.tabId }));
85
85
  }
86
86
  async type(t, text, opts) {
87
- return (await this.send('type', { ...targetParams(t), text, clear: opts?.clear, pressEnter: opts?.pressEnter, keyEvents: opts?.keyEvents }, { tabId: opts?.tabId }));
87
+ return (await this.send('type', { ...targetParams(t), text, clear: opts?.clear, pressEnter: opts?.pressEnter, keyEvents: opts?.keyEvents, trusted: opts?.trusted }, { tabId: opts?.tabId }));
88
+ }
89
+ async selectOption(t, values, opts) {
90
+ return (await this.send('select_option', { ...targetParams(t), values }, { tabId: opts?.tabId }));
88
91
  }
89
92
  async fill(t, value, opts) {
90
93
  // No dedicated wire method: a cleared insertText is the fill primitive.
@@ -106,6 +109,15 @@ class ExtensionExecutor {
106
109
  async getHtml(t, opts) {
107
110
  return (await this.send('get_html', { ...targetParams(t), outer: opts?.outer }, { tabId: opts?.tabId }));
108
111
  }
112
+ async snapshot(opts) {
113
+ return (await this.send('snapshot', { interactiveOnly: opts?.interactiveOnly, max: opts?.max }, { tabId: opts?.tabId }));
114
+ }
115
+ async getCookies(opts) {
116
+ return (await this.send('get_cookies', { url: opts?.url }, { tabId: opts?.tabId }));
117
+ }
118
+ async storage(args) {
119
+ return (await this.send('storage', { op: args.op, key: args.key, value: args.value, session: args.session }, { tabId: args.tabId }));
120
+ }
109
121
  async screenshot(opts) {
110
122
  return (await this.send('screenshot', { fullPage: opts?.fullPage, ...targetParams(opts?.target) }, { tabId: opts?.tabId }));
111
123
  }