@mehmoodqureshi/chrome-mcp 0.1.0 → 0.3.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;
@@ -35,11 +35,14 @@ function readPolicyFile(path) {
35
35
  */
36
36
  function parseArgs(argv) {
37
37
  let wsPort = envInt('CHROME_MCP_WS_PORT') ?? protocol_1.DEFAULT_WS_PORT;
38
- let cdpFallback = true;
38
+ // Extension-only by default: never launch/attach a Chromium of our own unless
39
+ // the operator explicitly opts in with --cdp-fallback (or --prefer cdp / --cdp-endpoint).
40
+ let cdpFallback = false;
39
41
  let cdpEndpoint;
40
42
  let prefer = 'extension';
41
43
  let headless = false;
42
44
  let printPairing = false;
45
+ let persistToken = false;
43
46
  let showHelp = false;
44
47
  let showVersion = false;
45
48
  let logLevel = 'info';
@@ -84,7 +87,10 @@ function parseArgs(argv) {
84
87
  case '--allow-all-tabs':
85
88
  policyFlags.allowAllTabs = true;
86
89
  break;
87
- case '--no-cdp-fallback':
90
+ case '--cdp-fallback':
91
+ cdpFallback = true;
92
+ break;
93
+ case '--no-cdp-fallback': // still accepted; fallback is already off by default
88
94
  cdpFallback = false;
89
95
  break;
90
96
  case '--cdp-endpoint':
@@ -99,6 +105,9 @@ function parseArgs(argv) {
99
105
  case '--print-pairing':
100
106
  printPairing = true;
101
107
  break;
108
+ case '--persist-token':
109
+ persistToken = true;
110
+ break;
102
111
  case '--log-level':
103
112
  logLevel = requireLogLevel(argv[++i]);
104
113
  break;
@@ -117,6 +126,7 @@ function parseArgs(argv) {
117
126
  prefer,
118
127
  headless,
119
128
  printPairing,
129
+ persistToken,
120
130
  showHelp,
121
131
  showVersion,
122
132
  logLevel,
@@ -164,9 +174,15 @@ Connection:
164
174
  --port <n> WebSocket bridge port (default ${protocol_1.DEFAULT_WS_PORT}; 0 = ephemeral)
165
175
  --data-dir <path> Override the data dir (default ~/.chrome-mcp)
166
176
  --print-pairing Write the handshake and print its path, then exit
177
+ --persist-token Reuse a stable on-disk token across restarts so the
178
+ extension never has to re-pair (default: fresh per boot).
179
+ CHROME_MCP_TOKEN env, if set, pins the token explicitly.
167
180
 
168
181
  Backend:
169
- --no-cdp-fallback Do not launch/attach Chromium when no extension is paired
182
+ --cdp-fallback Opt in to launching/attaching Chromium when no extension
183
+ is paired. OFF by default — extension-only, never opens
184
+ a browser of its own.
185
+ --no-cdp-fallback Explicitly disable the fallback (already the default).
170
186
  --cdp-endpoint <url> Attach to an existing Chrome (e.g. http://127.0.0.1:9222)
171
187
  --prefer <which> "extension" (default) or "cdp"
172
188
  --headless Run the CDP-fallback Chromium headless
@@ -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;