@mehmoodqureshi/chrome-mcp 0.3.0 → 0.4.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
@@ -32,7 +32,13 @@ Distributed as an `npx` CLI (the MCP server) plus a load-unpacked extension.
32
32
 
33
33
  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
- `--enable-downloads`, `--unsafe-enable-eval`, or `--unsafe-all-domains`.
35
+ `--enable-downloads`, `--enable-uploads`, `--unsafe-enable-eval`, or `--unsafe-all-domains`.
36
+
37
+ > `--enable-uploads` permits `upload_file` (setting local file(s) on a page's file
38
+ > `<input>`). It is **off by default** because sending local files to a page is an
39
+ > exfiltration risk; it is also gated by the destination-domain allowlist. Pair it
40
+ > with `--uploads-dir <path>` to restrict uploads to files inside that directory
41
+ > (`..` traversal is blocked) — strongly recommended for unattended use.
36
42
 
37
43
  **Drive only your real Chrome (recommended for the extension).** Add
38
44
  `--no-cdp-fallback` so the server never launches a separate Chromium, and
@@ -71,8 +77,9 @@ The tools cover tabs, navigation, interaction (`click`/`type`/`press`/`hover`/
71
77
  `scroll`/`select_option`), reads (`get_text`/`get_html`/`screenshot`/`eval`/`wait_for`),
72
78
  an accessibility `snapshot` (interactive elements with stable `ref`s the model can
73
79
  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`.
80
+ helpers (`extract_links`/`read_as_markdown`/`fill_form`/`download_file`/`upload_file`),
81
+ and `chrome_status`. `upload_file` sets local file(s) on a file `<input>` without the
82
+ OS dialog (requires `--enable-uploads`).
76
83
 
77
84
  `click`/`type` accept `trusted: true` for real OS-level input (works on
78
85
  React/Vue controlled inputs); interactions auto-wait for the target to appear.
@@ -91,7 +98,7 @@ token (`--persist-token`).
91
98
  (default-deny policy + capability gates), `src/config.ts` (CLI/env/policy
92
99
  resolution), build + test harness.
93
100
  - [x] **Phase 1 — MCP server + StubExecutor:** `mcp/server.ts` (clean-stdout
94
- stdio), `mcp/tools.ts` (23-tool catalog + never-throw dispatch +
101
+ stdio), `mcp/tools.ts` (28-tool catalog + never-throw dispatch +
95
102
  drift-check), validators/envelopes/helpers, `ExecutorManager` +
96
103
  `StubExecutor`, `cli.ts`. Point an MCP host at `node dist/src/cli.js` today.
97
104
  - [x] **Phase 2 — WebSocket bridge + auth:** `bridge/server.ts` (loopback WS,
@@ -32,10 +32,10 @@ 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' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | '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' | 'upload_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
- 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';
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' | 'UPLOAD_FAILED' | 'UNKNOWN_METHOD';
39
39
  export interface BaseFrame {
40
40
  type: string;
41
41
  v: ProtocolVersion;
@@ -54,6 +54,7 @@ exports.WIRE_METHODS = [
54
54
  'eval',
55
55
  'wait_for',
56
56
  'download_file',
57
+ 'upload_file',
57
58
  'ping_probe',
58
59
  ];
59
60
  //# sourceMappingURL=protocol.js.map
@@ -20,7 +20,30 @@ function collectSnapshot(interactiveOnly = true, max = 200) {
20
20
  if (r.width === 0 && r.height === 0)
21
21
  return false;
22
22
  const s = window.getComputedStyle(el);
23
- return s.visibility !== 'hidden' && s.display !== 'none';
23
+ if (s.visibility === 'hidden' || s.display === 'none')
24
+ return false;
25
+ // Prefer the native check (accounts for ancestors, content-visibility, etc.).
26
+ const cv = el.checkVisibility;
27
+ if (typeof cv === 'function') {
28
+ try {
29
+ return cv.call(el, { checkOpacity: false, checkVisibilityCSS: true });
30
+ }
31
+ catch {
32
+ /* fall through to manual ancestor walk */
33
+ }
34
+ }
35
+ // Fallback: walk ancestors for display:none / visibility:hidden. An element
36
+ // with no offsetParent (and not position:fixed) is detached/hidden.
37
+ let p = el.parentElement;
38
+ while (p) {
39
+ const ps = window.getComputedStyle(p);
40
+ if (ps.display === 'none' || ps.visibility === 'hidden')
41
+ return false;
42
+ p = p.parentElement;
43
+ }
44
+ if (el.offsetParent === null && s.position !== 'fixed')
45
+ return false;
46
+ return true;
24
47
  };
25
48
  const accName = (el) => {
26
49
  const aria = el.getAttribute('aria-label');
@@ -69,7 +92,46 @@ function collectSnapshot(interactiveOnly = true, max = 200) {
69
92
  }
70
93
  return tag;
71
94
  };
72
- const els = Array.from(document.querySelectorAll(sel)).filter(visible);
95
+ // Collect candidates across the light DOM *and* open shadow roots, descending
96
+ // recursively. Defensive against null/closed shadow roots and re-visits.
97
+ const seen = new Set();
98
+ const candidates = [];
99
+ const collect = (root) => {
100
+ if (candidates.length >= max)
101
+ return;
102
+ let matched;
103
+ try {
104
+ matched = Array.from(root.querySelectorAll(sel));
105
+ }
106
+ catch {
107
+ matched = [];
108
+ }
109
+ for (const el of matched) {
110
+ if (candidates.length >= max)
111
+ break;
112
+ if (seen.has(el))
113
+ continue;
114
+ seen.add(el);
115
+ candidates.push(el);
116
+ }
117
+ // Descend into any open shadow roots hosted under this root.
118
+ let hosts;
119
+ try {
120
+ hosts = Array.from(root.querySelectorAll('*'));
121
+ }
122
+ catch {
123
+ hosts = [];
124
+ }
125
+ for (const host of hosts) {
126
+ if (candidates.length >= max)
127
+ break;
128
+ const sr = host.shadowRoot;
129
+ if (sr)
130
+ collect(sr);
131
+ }
132
+ };
133
+ collect(document);
134
+ const els = candidates.filter(visible);
73
135
  const nodes = [];
74
136
  let n = 0;
75
137
  for (const el of els) {
@@ -21,6 +21,8 @@ const connection_1 = require("./connection");
21
21
  const auth_1 = require("./auth");
22
22
  const HELLO_TIMEOUT_MS = 5_000;
23
23
  const DEFAULT_HEARTBEAT_MS = 15_000;
24
+ /** Max pre-auth frames a socket may send before a valid hello (anti-idle-hold). */
25
+ const MAX_PREAUTH_FRAMES = 10;
24
26
  class BridgeServer {
25
27
  opts;
26
28
  wss = null;
@@ -78,6 +80,9 @@ class BridgeServer {
78
80
  // -- internals ----------------------------------------------------------
79
81
  handleConnection(ws) {
80
82
  let authed = false;
83
+ // Cap pre-auth frames so a peer can't hold a socket idle by streaming
84
+ // non-hello noise until HELLO_TIMEOUT_MS.
85
+ let preAuthFrames = 0;
81
86
  const helloTimer = setTimeout(() => {
82
87
  if (authed)
83
88
  return;
@@ -87,6 +92,11 @@ class BridgeServer {
87
92
  const onMessage = (raw) => {
88
93
  if (authed)
89
94
  return;
95
+ if (++preAuthFrames > MAX_PREAUTH_FRAMES) {
96
+ clearTimeout(helloTimer);
97
+ this.reject(ws, 'bad_token');
98
+ return;
99
+ }
90
100
  let frame;
91
101
  try {
92
102
  frame = JSON.parse(raw.toString());
@@ -136,7 +146,13 @@ class BridgeServer {
136
146
  const differentId = prev.extId !== ext.id;
137
147
  this.log(`extension "${ext.id}" superseded active connection "${prev.extId}"` +
138
148
  (differentId ? ' (DIFFERENT id — possible hijack; surfaced to status)' : ''));
139
- this.opts.onDisplacement?.({ oldExtId: prev.extId, newExtId: ext.id, differentId });
149
+ try {
150
+ this.opts.onDisplacement?.({ oldExtId: prev.extId, newExtId: ext.id, differentId });
151
+ }
152
+ catch {
153
+ // A throwing displacement callback must not take down the bridge.
154
+ this.log('onDisplacement callback threw; ignored');
155
+ }
140
156
  prev.close(protocol_1.CLOSE_SUPERSEDED, 'superseded');
141
157
  }
142
158
  const conn = new connection_1.ExtensionConnection({
package/dist/src/cli.js CHANGED
@@ -19,6 +19,27 @@ const server_1 = require("./bridge/server");
19
19
  const datadir_1 = require("./bridge/datadir");
20
20
  const auth_1 = require("./bridge/auth");
21
21
  const server_2 = require("./mcp/server");
22
+ /** Hard deadline for clean shutdown before we force-exit (a stuck socket must not hang us). */
23
+ const SHUTDOWN_DEADLINE_MS = 3000;
24
+ /**
25
+ * Race a best-effort shutdown against a hard deadline, then exit. The timer is
26
+ * unref'd so it never itself keeps the process alive; if it fires first we log a
27
+ * brief note that clean shutdown did not complete in time.
28
+ */
29
+ function exitWithDeadline(work) {
30
+ let timer;
31
+ const deadline = new Promise((resolve) => {
32
+ timer = setTimeout(() => resolve('timeout'), SHUTDOWN_DEADLINE_MS);
33
+ timer.unref();
34
+ });
35
+ void Promise.race([work.then(() => 'clean'), deadline]).then((outcome) => {
36
+ clearTimeout(timer);
37
+ if (outcome === 'timeout') {
38
+ (0, server_2.logErr)(`shutdown deadline (${SHUTDOWN_DEADLINE_MS}ms) hit before clean shutdown; forcing exit.`);
39
+ }
40
+ process.exit(0);
41
+ });
42
+ }
22
43
  function version() {
23
44
  try {
24
45
  const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, '..', '..', 'package.json'), 'utf8'));
@@ -65,7 +86,7 @@ async function main() {
65
86
  (0, server_2.logErr)('pairing mode — bridge is up; open the extension and pair, then Ctrl-C.');
66
87
  process.on('SIGINT', () => {
67
88
  cleanup();
68
- void bridge.stop().finally(() => process.exit(0));
89
+ exitWithDeadline(bridge.stop());
69
90
  });
70
91
  return;
71
92
  }
@@ -86,11 +107,11 @@ async function main() {
86
107
  (0, server_2.logErr)(`backend: extension-if-paired else ${cfg.cdpFallback ? 'CDP fallback' : 'none'} (prefer: ${cfg.prefer})`);
87
108
  const shutdown = () => {
88
109
  cleanup();
89
- void Promise.allSettled([(0, server_2.stopMcpServer)(), bridge.stop()]).finally(() => process.exit(0));
110
+ exitWithDeadline(Promise.allSettled([(0, server_2.stopMcpServer)(), bridge.stop()]));
90
111
  };
91
112
  process.on('SIGINT', shutdown);
92
113
  process.on('SIGTERM', shutdown);
93
- await (0, server_2.startMcpServer)();
114
+ await (0, server_2.startMcpServer)(version());
94
115
  }
95
116
  main().catch((err) => {
96
117
  (0, server_2.logErr)(`fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
@@ -84,6 +84,12 @@ function parseArgs(argv) {
84
84
  case '--enable-downloads':
85
85
  policyFlags.allowDownloads = true;
86
86
  break;
87
+ case '--enable-uploads':
88
+ policyFlags.allowUploads = true;
89
+ break;
90
+ case '--uploads-dir':
91
+ policyFlags.uploadsDir = (0, node_path_1.resolve)(requireValue(argv[++i], '--uploads-dir'));
92
+ break;
87
93
  case '--allow-all-tabs':
88
94
  policyFlags.allowAllTabs = true;
89
95
  break;
@@ -194,6 +200,8 @@ Security (default: deny-all safe mode):
194
200
  --enable-mutations Enable click/type/navigate/… (off by default)
195
201
  --unsafe-enable-eval Enable the eval primitive (off by default)
196
202
  --enable-downloads Enable download_file (off by default)
203
+ --enable-uploads Enable upload_file — sends local files to a page (off by default)
204
+ --uploads-dir <path> Restrict upload_file to files inside <path> (recommended with --enable-uploads)
197
205
  --allow-all-tabs Relax tab list/select to all tabs
198
206
 
199
207
  Misc:
@@ -45,6 +45,13 @@ export declare class CdpExecutor implements Executor {
45
45
  private contentPages;
46
46
  private resolveTab;
47
47
  private locator;
48
+ /**
49
+ * Run a live-page operation, converting an opaque "target closed"/crashed
50
+ * error from Playwright into a clean DETACHED. On such a failure we also drop
51
+ * the cached context/tabs so the next call relaunches or reconnects cleanly.
52
+ * Any other error is rethrown unchanged.
53
+ */
54
+ private guard;
48
55
  tabsList(): Promise<TabInfo[]>;
49
56
  tabSelect(tabId: TabId): Promise<TabInfo>;
50
57
  tabNew(url?: string): Promise<TabInfo>;
@@ -151,4 +158,7 @@ export declare class CdpExecutor implements Executor {
151
158
  tabId?: TabId;
152
159
  suggestedName?: string;
153
160
  }): Promise<DownloadResult>;
161
+ uploadFile(t: Target, files: string[], opts?: {
162
+ tabId?: TabId;
163
+ }): Promise<ActionOk>;
154
164
  }