@mehmoodqureshi/chrome-mcp 0.4.1 → 0.5.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 +51 -11
- package/dist/shared/mutex.d.ts +18 -0
- package/dist/shared/mutex.js +42 -0
- package/dist/shared/policy.d.ts +34 -0
- package/dist/shared/policy.js +151 -0
- package/dist/shared/protocol.d.ts +15 -0
- package/dist/shared/screenshot.d.ts +58 -0
- package/dist/shared/screenshot.js +54 -0
- package/dist/src/bridge/server.d.ts +4 -1
- package/dist/src/bridge/server.js +2 -0
- package/dist/src/cli.js +3 -0
- package/dist/src/executor/cdp-executor.d.ts +3 -1
- package/dist/src/executor/cdp-executor.js +3 -1
- package/dist/src/executor/extension-executor.d.ts +3 -1
- package/dist/src/executor/extension-executor.js +2 -2
- package/dist/src/executor/stub-executor.d.ts +3 -1
- package/dist/src/executor/stub-executor.js +1 -1
- package/dist/src/executor/types.d.ts +4 -1
- package/dist/src/mcp/batch.d.ts +26 -0
- package/dist/src/mcp/batch.js +130 -0
- package/dist/src/mcp/tools.js +29 -3
- package/dist/src/security/policy.d.ts +16 -34
- package/dist/src/security/policy.js +20 -124
- package/docs/BLUEPRINT.md +9 -4
- package/extension-dist/background.js +281 -42
- package/extension-dist/manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,14 +84,52 @@ OS dialog (requires `--enable-uploads`).
|
|
|
84
84
|
`click`/`type` accept `trusted: true` for real OS-level input (works on
|
|
85
85
|
React/Vue controlled inputs); interactions auto-wait for the target to appear.
|
|
86
86
|
|
|
87
|
+
### Driving several tabs at once — `batch`
|
|
88
|
+
|
|
89
|
+
`batch` runs many tool calls in **one** request — `parallel` (default) or
|
|
90
|
+
`serial` (with optional `stopOnError`). Each sub-op goes through the **same**
|
|
91
|
+
policy gate, rate limit, and error handling as a direct call (no bypass,
|
|
92
|
+
no nesting). Use it to fan work out across tabs:
|
|
93
|
+
|
|
94
|
+
```jsonc
|
|
95
|
+
// open three product pages (background, so they don't fight for focus)…
|
|
96
|
+
{ "name": "batch", "arguments": { "ops": [
|
|
97
|
+
{ "tool": "tab_new", "args": { "url": "https://a.example/p" } },
|
|
98
|
+
{ "tool": "tab_new", "args": { "url": "https://b.example/p" } },
|
|
99
|
+
{ "tool": "tab_new", "args": { "url": "https://c.example/p" } }
|
|
100
|
+
]}}
|
|
101
|
+
|
|
102
|
+
// …then read them all at once (wall-clock ≈ the slowest one, not the sum)
|
|
103
|
+
{ "name": "batch", "arguments": { "ops": [
|
|
104
|
+
{ "tool": "get_text", "args": { "tabId": "<a tabId>" } },
|
|
105
|
+
{ "tool": "get_text", "args": { "tabId": "<b tabId>" } },
|
|
106
|
+
{ "tool": "get_text", "args": { "tabId": "<c tabId>" } }
|
|
107
|
+
]}}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
In `parallel` mode, tab-scoped ops **must** pass an explicit `tabId` — the
|
|
111
|
+
active-tab default is unsafe under concurrency, so it's rejected rather than
|
|
112
|
+
silently mis-routed. (`tab_new`, `tabs_list`, `chrome_status` are exempt.)
|
|
113
|
+
|
|
114
|
+
> **`tab_new` focuses the new tab by default** (so "open X" behaves like opening
|
|
115
|
+
> a link, instead of replacing your current page — use `tab_new`, not
|
|
116
|
+
> `navigate`, to open without losing the current tab). Pass `active: false` to
|
|
117
|
+
> open in the background; parallel batches do this automatically.
|
|
118
|
+
|
|
87
119
|
## Status
|
|
88
120
|
|
|
89
|
-
v0.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
121
|
+
v0.5.0 — **safe multi-tab concurrency.** Adds the `batch` fan-out tool, makes
|
|
122
|
+
parallel tab automation race-free (explicit-`tabId` guard; per-tab
|
|
123
|
+
`chrome.debugger` serialization; collision-free `tab_new`), captures screenshots
|
|
124
|
+
via `chrome.debugger` (a specific tab without stealing focus — plus true
|
|
125
|
+
full-page and element capture), and focuses newly opened tabs by default. 111
|
|
126
|
+
automated tests + a gated headed extension smoke.
|
|
127
|
+
|
|
128
|
+
v0.2.0 — all six build phases complete and green. End-to-end working:
|
|
129
|
+
`npx chrome-mcp` ⇄ bridge ⇄ extension ⇄ your real Chrome, with a Playwright CDP
|
|
130
|
+
fallback. v0.2 adds the accessibility `snapshot` + element refs, auto-wait,
|
|
131
|
+
cookies/storage/`select_option`, trusted input (`chrome.debugger`), a toolbar
|
|
132
|
+
status badge, and a stable pairing token (`--persist-token`).
|
|
95
133
|
|
|
96
134
|
- [x] **Phase 0 — Contracts & skeleton:** `shared/protocol.ts` (wire contract),
|
|
97
135
|
`src/executor/types.ts` (Executor interface), `src/security/policy.ts`
|
|
@@ -162,8 +200,10 @@ from the extension's **Options** page using the `port` + `token` from
|
|
|
162
200
|
`~/.chrome-mcp/handshake.json` (run `npx chrome-mcp --print-pairing` to get the
|
|
163
201
|
path).
|
|
164
202
|
|
|
165
|
-
> **
|
|
166
|
-
>
|
|
167
|
-
>
|
|
168
|
-
> OS-level
|
|
169
|
-
>
|
|
203
|
+
> **Reads/interaction use `chrome.scripting`/`chrome.tabs`** — no "is being
|
|
204
|
+
> debugged" banner, CSP-safe reads (isolated world), testable under Playwright.
|
|
205
|
+
> `chrome.debugger` is used only where it's needed and worth it: `trusted: true`
|
|
206
|
+
> input (real OS-level events on React/Vue inputs) and `screenshot` (captures a
|
|
207
|
+
> specific tab **without** activating it — safe under parallel `batch` — with
|
|
208
|
+
> true full-page and element capture). Those ops briefly show the debug banner
|
|
209
|
+
> while attached.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/mutex.ts — a tiny per-key async lock.
|
|
3
|
+
*
|
|
4
|
+
* Calls sharing a key run one-at-a-time in FIFO order; different keys run
|
|
5
|
+
* concurrently. Pure promises, no platform deps, so it's reusable by the
|
|
6
|
+
* extension service worker and unit-testable without Chrome.
|
|
7
|
+
*
|
|
8
|
+
* Used in the SW to serialize chrome.debugger attach→work→detach per tab (a
|
|
9
|
+
* second attach on the same tab throws, and one op's detach would yank the
|
|
10
|
+
* debugger from a concurrent op) while keeping different tabs parallel.
|
|
11
|
+
*/
|
|
12
|
+
export declare class KeyedMutex {
|
|
13
|
+
private readonly tails;
|
|
14
|
+
/** Run `fn` after all earlier holders of `key` settle. Resolves/rejects with fn's outcome. */
|
|
15
|
+
run<T>(key: string, fn: () => Promise<T> | T): Promise<T>;
|
|
16
|
+
/** Number of keys with an outstanding or queued holder (for tests/inspection). */
|
|
17
|
+
get size(): number;
|
|
18
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* shared/mutex.ts — a tiny per-key async lock.
|
|
4
|
+
*
|
|
5
|
+
* Calls sharing a key run one-at-a-time in FIFO order; different keys run
|
|
6
|
+
* concurrently. Pure promises, no platform deps, so it's reusable by the
|
|
7
|
+
* extension service worker and unit-testable without Chrome.
|
|
8
|
+
*
|
|
9
|
+
* Used in the SW to serialize chrome.debugger attach→work→detach per tab (a
|
|
10
|
+
* second attach on the same tab throws, and one op's detach would yank the
|
|
11
|
+
* debugger from a concurrent op) while keeping different tabs parallel.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.KeyedMutex = void 0;
|
|
15
|
+
function noop() {
|
|
16
|
+
/* swallow — the tracker must never reject so the queue keeps flowing */
|
|
17
|
+
}
|
|
18
|
+
class KeyedMutex {
|
|
19
|
+
tails = new Map();
|
|
20
|
+
/** Run `fn` after all earlier holders of `key` settle. Resolves/rejects with fn's outcome. */
|
|
21
|
+
run(key, fn) {
|
|
22
|
+
const prev = this.tails.get(key) ?? Promise.resolve();
|
|
23
|
+
// Run fn whether the previous holder resolved or rejected (its failure must
|
|
24
|
+
// not poison this one).
|
|
25
|
+
const result = prev.then(fn, fn);
|
|
26
|
+
// A non-rejecting tracker the next waiter chains on.
|
|
27
|
+
const tail = result.then(noop, noop);
|
|
28
|
+
this.tails.set(key, tail);
|
|
29
|
+
void tail.then(() => {
|
|
30
|
+
// Only drop the entry if no newer waiter has replaced it.
|
|
31
|
+
if (this.tails.get(key) === tail)
|
|
32
|
+
this.tails.delete(key);
|
|
33
|
+
});
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
/** Number of keys with an outstanding or queued holder (for tests/inspection). */
|
|
37
|
+
get size() {
|
|
38
|
+
return this.tails.size;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
exports.KeyedMutex = KeyedMutex;
|
|
42
|
+
//# sourceMappingURL=mutex.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/policy.ts — the PURE policy decision, imported VERBATIM by both the
|
|
3
|
+
* server (src/security/policy.ts wraps it to throw ExecutorError) and the
|
|
4
|
+
* extension (router mirrors it to throw CmdError). Keeping the decision in one
|
|
5
|
+
* shared module is what makes the two ends provably agree — the gate is then
|
|
6
|
+
* genuinely enforced "at both ends" (server dispatch AND extension router).
|
|
7
|
+
*
|
|
8
|
+
* No throwing, no Node/Chrome deps: returns a verdict the caller renders into
|
|
9
|
+
* whatever error type it uses.
|
|
10
|
+
*/
|
|
11
|
+
import type { WireMethod, WirePolicy } from './protocol';
|
|
12
|
+
export declare function isReadMethod(method: WireMethod): boolean;
|
|
13
|
+
/** Everything in the mutating tool set that safe-mode disables. `eval` is gated
|
|
14
|
+
* separately via `allowEval`; `download_file` separately via `allowDownloads`. */
|
|
15
|
+
export declare function isMutatingMethod(method: WireMethod): boolean;
|
|
16
|
+
/** Whether the method touches a specific URL that must be allowlisted. */
|
|
17
|
+
export declare function isUrlGated(method: WireMethod): boolean;
|
|
18
|
+
/** Parse a host out of a URL string; '' if it has none (about:blank, data:, …). */
|
|
19
|
+
export declare function hostOf(url: string): string;
|
|
20
|
+
export declare function isDomainAllowed(url: string, policy: WirePolicy): boolean;
|
|
21
|
+
export type PolicyVerdict = {
|
|
22
|
+
ok: true;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
reason: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Decide whether `method` against `url` is permitted by `policy`. `url` is the
|
|
29
|
+
* DESTINATION for navigation, otherwise the current tab URL. Pure: returns a
|
|
30
|
+
* verdict; the caller throws its own error type on `{ ok: false }`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function evaluatePolicy(url: string, method: WireMethod, policy: WirePolicy): PolicyVerdict;
|
|
33
|
+
/** A wire policy that allows nothing — the safe default when none was delivered. */
|
|
34
|
+
export declare const DENY_ALL_WIRE_POLICY: WirePolicy;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* shared/policy.ts — the PURE policy decision, imported VERBATIM by both the
|
|
4
|
+
* server (src/security/policy.ts wraps it to throw ExecutorError) and the
|
|
5
|
+
* extension (router mirrors it to throw CmdError). Keeping the decision in one
|
|
6
|
+
* shared module is what makes the two ends provably agree — the gate is then
|
|
7
|
+
* genuinely enforced "at both ends" (server dispatch AND extension router).
|
|
8
|
+
*
|
|
9
|
+
* No throwing, no Node/Chrome deps: returns a verdict the caller renders into
|
|
10
|
+
* whatever error type it uses.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.DENY_ALL_WIRE_POLICY = void 0;
|
|
14
|
+
exports.isReadMethod = isReadMethod;
|
|
15
|
+
exports.isMutatingMethod = isMutatingMethod;
|
|
16
|
+
exports.isUrlGated = isUrlGated;
|
|
17
|
+
exports.hostOf = hostOf;
|
|
18
|
+
exports.isDomainAllowed = isDomainAllowed;
|
|
19
|
+
exports.evaluatePolicy = evaluatePolicy;
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Method classification
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/** Methods that read page CONTENT (the exfil payload) — URL-gated. */
|
|
24
|
+
const READ_CONTENT = new Set([
|
|
25
|
+
'get_text',
|
|
26
|
+
'get_html',
|
|
27
|
+
'screenshot',
|
|
28
|
+
'wait_for',
|
|
29
|
+
]);
|
|
30
|
+
/** Content-mutating actions — URL-gated AND mutation-gated. */
|
|
31
|
+
const MUTATE_CONTENT = new Set([
|
|
32
|
+
'click',
|
|
33
|
+
'type',
|
|
34
|
+
'press',
|
|
35
|
+
'hover',
|
|
36
|
+
'scroll',
|
|
37
|
+
]);
|
|
38
|
+
/** Navigation — URL-gated by the DESTINATION url, and mutation-gated. */
|
|
39
|
+
const NAVIGATION = new Set([
|
|
40
|
+
'navigate',
|
|
41
|
+
'back',
|
|
42
|
+
'forward',
|
|
43
|
+
'reload',
|
|
44
|
+
]);
|
|
45
|
+
/** Tab management — mutation-gated, but not content-URL-gated. */
|
|
46
|
+
const TAB_MUTATE = new Set([
|
|
47
|
+
'tab_select',
|
|
48
|
+
'tab_new',
|
|
49
|
+
'tab_close',
|
|
50
|
+
]);
|
|
51
|
+
function isReadMethod(method) {
|
|
52
|
+
return READ_CONTENT.has(method) || method === 'tabs_list';
|
|
53
|
+
}
|
|
54
|
+
/** Everything in the mutating tool set that safe-mode disables. `eval` is gated
|
|
55
|
+
* separately via `allowEval`; `download_file` separately via `allowDownloads`. */
|
|
56
|
+
function isMutatingMethod(method) {
|
|
57
|
+
return MUTATE_CONTENT.has(method) || NAVIGATION.has(method) || TAB_MUTATE.has(method);
|
|
58
|
+
}
|
|
59
|
+
/** Whether the method touches a specific URL that must be allowlisted. */
|
|
60
|
+
function isUrlGated(method) {
|
|
61
|
+
return (READ_CONTENT.has(method) ||
|
|
62
|
+
MUTATE_CONTENT.has(method) ||
|
|
63
|
+
NAVIGATION.has(method) ||
|
|
64
|
+
method === 'eval' ||
|
|
65
|
+
method === 'upload_file');
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Domain matching
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
/** Parse a host out of a URL string; '' if it has none (about:blank, data:, …). */
|
|
71
|
+
function hostOf(url) {
|
|
72
|
+
try {
|
|
73
|
+
return new URL(url).hostname.toLowerCase();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function isAboutBlank(url) {
|
|
80
|
+
return url === 'about:blank' || url === '' || url.startsWith('about:');
|
|
81
|
+
}
|
|
82
|
+
/** Convert a single domain glob to a predicate. '*' matches everything;
|
|
83
|
+
* '*.example.com' matches example.com and any subdomain; otherwise exact host. */
|
|
84
|
+
function globMatches(host, pattern) {
|
|
85
|
+
const p = pattern.trim().toLowerCase();
|
|
86
|
+
if (p === '*' || p === '*://*/*')
|
|
87
|
+
return true;
|
|
88
|
+
if (p.startsWith('*.')) {
|
|
89
|
+
const base = p.slice(2);
|
|
90
|
+
return host === base || host.endsWith('.' + base);
|
|
91
|
+
}
|
|
92
|
+
return host === p;
|
|
93
|
+
}
|
|
94
|
+
function isDomainAllowed(url, policy) {
|
|
95
|
+
const host = hostOf(url);
|
|
96
|
+
if (!host)
|
|
97
|
+
return false;
|
|
98
|
+
return policy.allowDomains.some((pat) => globMatches(host, pat));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Decide whether `method` against `url` is permitted by `policy`. `url` is the
|
|
102
|
+
* DESTINATION for navigation, otherwise the current tab URL. Pure: returns a
|
|
103
|
+
* verdict; the caller throws its own error type on `{ ok: false }`.
|
|
104
|
+
*/
|
|
105
|
+
function evaluatePolicy(url, method, policy) {
|
|
106
|
+
// -- capability gates (independent of URL) --
|
|
107
|
+
if (method === 'eval' && !policy.allowEval) {
|
|
108
|
+
return { ok: false, reason: 'eval is disabled (safe-mode). Pass --unsafe-enable-eval to allow it.' };
|
|
109
|
+
}
|
|
110
|
+
if (method === 'download_file' && !policy.allowDownloads) {
|
|
111
|
+
return { ok: false, reason: 'downloads are disabled. Pass --enable-downloads or set allowDownloads.' };
|
|
112
|
+
}
|
|
113
|
+
if (method === 'upload_file' && !policy.allowUploads) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
reason: 'uploads are disabled (sending local files to a page is an exfiltration risk). ' +
|
|
117
|
+
'Pass --enable-uploads or set allowUploads.',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (isMutatingMethod(method) && !policy.enableMutations) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
reason: `mutating tool "${method}" is disabled (safe-mode). Pass --enable-mutations to allow it.`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Methods that don't carry a content URL pass once the gates above are clear.
|
|
127
|
+
if (!isUrlGated(method))
|
|
128
|
+
return { ok: true };
|
|
129
|
+
// Navigating to a blank page is always fine.
|
|
130
|
+
if (isAboutBlank(url) && NAVIGATION.has(method))
|
|
131
|
+
return { ok: true };
|
|
132
|
+
if (!isDomainAllowed(url, policy)) {
|
|
133
|
+
const host = hostOf(url) || url;
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: `"${method}" denied: ${host} is not in the domain allowlist. ` +
|
|
137
|
+
`Add it to allowDomains, or pass --unsafe-all-domains.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return { ok: true };
|
|
141
|
+
}
|
|
142
|
+
/** A wire policy that allows nothing — the safe default when none was delivered. */
|
|
143
|
+
exports.DENY_ALL_WIRE_POLICY = {
|
|
144
|
+
allowDomains: [],
|
|
145
|
+
allowEval: false,
|
|
146
|
+
allowDownloads: false,
|
|
147
|
+
allowUploads: false,
|
|
148
|
+
allowAllTabs: false,
|
|
149
|
+
enableMutations: false,
|
|
150
|
+
};
|
|
151
|
+
//# sourceMappingURL=policy.js.map
|
|
@@ -49,11 +49,26 @@ export interface HelloFrame extends BaseFrame {
|
|
|
49
49
|
chrome: string;
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The wire-serializable subset of the server's policy, delivered in `welcome` so
|
|
54
|
+
* the extension can enforce the SAME gate the server does (defense-in-depth). The
|
|
55
|
+
* server-only `uploadsDir` (a local filesystem path) is intentionally NOT sent.
|
|
56
|
+
*/
|
|
57
|
+
export interface WirePolicy {
|
|
58
|
+
allowDomains: string[];
|
|
59
|
+
allowEval: boolean;
|
|
60
|
+
allowDownloads: boolean;
|
|
61
|
+
allowUploads: boolean;
|
|
62
|
+
allowAllTabs: boolean;
|
|
63
|
+
enableMutations: boolean;
|
|
64
|
+
}
|
|
52
65
|
export interface WelcomeFrame extends BaseFrame {
|
|
53
66
|
type: 'welcome';
|
|
54
67
|
serverVersion: string;
|
|
55
68
|
sessionId: string;
|
|
56
69
|
heartbeatMs: number;
|
|
70
|
+
/** The active policy, so the extension can mirror the server-side gate. */
|
|
71
|
+
policy: WirePolicy;
|
|
57
72
|
}
|
|
58
73
|
export interface UnauthFrame extends BaseFrame {
|
|
59
74
|
type: 'unauthorized';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/screenshot.ts — pure screenshot-planning logic, shared so it can be
|
|
3
|
+
* unit-tested without Chrome and reused by the extension SW.
|
|
4
|
+
*
|
|
5
|
+
* Turns measured page dimensions (and an optional element rect) into the
|
|
6
|
+
* `Page.captureScreenshot` clip + the logical dimensions/truncation flags the
|
|
7
|
+
* `ScreenshotResult` reports. No chrome.* calls — just arithmetic.
|
|
8
|
+
*/
|
|
9
|
+
/** Measured page geometry, in CSS pixels. */
|
|
10
|
+
export interface PageDims {
|
|
11
|
+
/** Viewport width/height. */
|
|
12
|
+
w: number;
|
|
13
|
+
h: number;
|
|
14
|
+
/** Full content box (document) width/height. */
|
|
15
|
+
fullW: number;
|
|
16
|
+
fullH: number;
|
|
17
|
+
}
|
|
18
|
+
/** An element's box in DOCUMENT coordinates (viewport rect + scroll offset), CSS px. */
|
|
19
|
+
export interface ElementRect {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
w: number;
|
|
23
|
+
h: number;
|
|
24
|
+
}
|
|
25
|
+
/** A CDP `Page.captureScreenshot` clip (CSS px; `scale` multiplies output). */
|
|
26
|
+
export interface CaptureClip {
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
scale: number;
|
|
32
|
+
}
|
|
33
|
+
export interface ScreenshotPlan {
|
|
34
|
+
/** Omitted for a plain viewport capture (capture whatever is visible). */
|
|
35
|
+
clip?: CaptureClip;
|
|
36
|
+
/** Must be true whenever a clip reaches outside the current viewport. */
|
|
37
|
+
captureBeyondViewport: boolean;
|
|
38
|
+
/** Logical (CSS px) dimensions to report back in ScreenshotResult. */
|
|
39
|
+
width: number;
|
|
40
|
+
height: number;
|
|
41
|
+
/** The capture was clamped below the real content/element height. */
|
|
42
|
+
truncated: boolean;
|
|
43
|
+
/** The true height when `truncated` (or for any fullPage/element capture). */
|
|
44
|
+
fullHeight?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Practical single-capture height ceiling. Skia/CDP cannot encode arbitrarily
|
|
48
|
+
* tall images; beyond this we clamp the clip and flag `truncated`.
|
|
49
|
+
*/
|
|
50
|
+
export declare const MAX_CAPTURE_PX = 16384;
|
|
51
|
+
/**
|
|
52
|
+
* Plan a capture. Element clip wins over fullPage; fullPage wins over the plain
|
|
53
|
+
* viewport capture. Heights are clamped to MAX_CAPTURE_PX with `truncated` set.
|
|
54
|
+
*/
|
|
55
|
+
export declare function planScreenshot(dims: PageDims, opts?: {
|
|
56
|
+
fullPage?: boolean;
|
|
57
|
+
element?: ElementRect | null;
|
|
58
|
+
}): ScreenshotPlan;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* shared/screenshot.ts — pure screenshot-planning logic, shared so it can be
|
|
4
|
+
* unit-tested without Chrome and reused by the extension SW.
|
|
5
|
+
*
|
|
6
|
+
* Turns measured page dimensions (and an optional element rect) into the
|
|
7
|
+
* `Page.captureScreenshot` clip + the logical dimensions/truncation flags the
|
|
8
|
+
* `ScreenshotResult` reports. No chrome.* calls — just arithmetic.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.MAX_CAPTURE_PX = void 0;
|
|
12
|
+
exports.planScreenshot = planScreenshot;
|
|
13
|
+
/**
|
|
14
|
+
* Practical single-capture height ceiling. Skia/CDP cannot encode arbitrarily
|
|
15
|
+
* tall images; beyond this we clamp the clip and flag `truncated`.
|
|
16
|
+
*/
|
|
17
|
+
exports.MAX_CAPTURE_PX = 16384;
|
|
18
|
+
/**
|
|
19
|
+
* Plan a capture. Element clip wins over fullPage; fullPage wins over the plain
|
|
20
|
+
* viewport capture. Heights are clamped to MAX_CAPTURE_PX with `truncated` set.
|
|
21
|
+
*/
|
|
22
|
+
function planScreenshot(dims, opts = {}) {
|
|
23
|
+
if (opts.element) {
|
|
24
|
+
const realH = Math.max(1, Math.round(opts.element.h));
|
|
25
|
+
const clipH = Math.min(opts.element.h, exports.MAX_CAPTURE_PX);
|
|
26
|
+
return {
|
|
27
|
+
clip: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale: 1 },
|
|
28
|
+
captureBeyondViewport: true,
|
|
29
|
+
width: Math.max(1, Math.round(opts.element.w)),
|
|
30
|
+
height: Math.min(realH, exports.MAX_CAPTURE_PX),
|
|
31
|
+
truncated: realH > exports.MAX_CAPTURE_PX,
|
|
32
|
+
fullHeight: realH,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (opts.fullPage) {
|
|
36
|
+
const clipH = Math.min(dims.fullH, exports.MAX_CAPTURE_PX);
|
|
37
|
+
return {
|
|
38
|
+
clip: { x: 0, y: 0, width: dims.fullW, height: clipH, scale: 1 },
|
|
39
|
+
captureBeyondViewport: true,
|
|
40
|
+
width: dims.fullW,
|
|
41
|
+
height: clipH,
|
|
42
|
+
truncated: dims.fullH > clipH,
|
|
43
|
+
fullHeight: dims.fullH,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Plain viewport: no clip, capture what's visible.
|
|
47
|
+
return {
|
|
48
|
+
captureBeyondViewport: false,
|
|
49
|
+
width: dims.w,
|
|
50
|
+
height: dims.h,
|
|
51
|
+
truncated: false,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=screenshot.js.map
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* 3. On success, send `welcome`, promote to the single ACTIVE connection
|
|
11
11
|
* (superseding any prior one — a security-relevant displacement event).
|
|
12
12
|
*/
|
|
13
|
-
import { type WireEvent, type WireMethod } from '../../shared/protocol';
|
|
13
|
+
import { type WireEvent, type WireMethod, type WirePolicy } from '../../shared/protocol';
|
|
14
14
|
export interface DisplacementInfo {
|
|
15
15
|
oldExtId: string;
|
|
16
16
|
newExtId: string;
|
|
@@ -20,6 +20,9 @@ export interface DisplacementInfo {
|
|
|
20
20
|
export interface BridgeOptions {
|
|
21
21
|
token: string;
|
|
22
22
|
serverVersion: string;
|
|
23
|
+
/** Active policy, sent to the extension in `welcome` so it mirrors the gate.
|
|
24
|
+
* Defaults to deny-all if omitted. */
|
|
25
|
+
policy?: WirePolicy;
|
|
23
26
|
port?: number;
|
|
24
27
|
host?: string;
|
|
25
28
|
heartbeatMs?: number;
|
|
@@ -16,6 +16,7 @@ exports.BridgeServer = void 0;
|
|
|
16
16
|
const ws_1 = require("ws");
|
|
17
17
|
const node_crypto_1 = require("node:crypto");
|
|
18
18
|
const protocol_1 = require("../../shared/protocol");
|
|
19
|
+
const policy_1 = require("../../shared/policy");
|
|
19
20
|
const types_1 = require("../executor/types");
|
|
20
21
|
const connection_1 = require("./connection");
|
|
21
22
|
const auth_1 = require("./auth");
|
|
@@ -174,6 +175,7 @@ class BridgeServer {
|
|
|
174
175
|
serverVersion: this.opts.serverVersion,
|
|
175
176
|
sessionId,
|
|
176
177
|
heartbeatMs: this.heartbeatMs,
|
|
178
|
+
policy: this.opts.policy ?? policy_1.DENY_ALL_WIRE_POLICY,
|
|
177
179
|
};
|
|
178
180
|
this.send(ws, welcome);
|
|
179
181
|
this.log(`extension paired (session ${sessionId}, id "${ext.id}")`);
|
package/dist/src/cli.js
CHANGED
|
@@ -61,9 +61,12 @@ async function main() {
|
|
|
61
61
|
}
|
|
62
62
|
const dataDir = (0, datadir_1.ensureDataDir)(cfg.dataDir);
|
|
63
63
|
const token = (0, auth_1.resolveToken)(dataDir, { persist: cfg.persistToken });
|
|
64
|
+
const { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations } = cfg.policy;
|
|
64
65
|
const bridge = new server_1.BridgeServer({
|
|
65
66
|
token,
|
|
66
67
|
serverVersion: version(),
|
|
68
|
+
// Wire-serializable policy subset (no local uploadsDir) so the extension mirrors the gate.
|
|
69
|
+
policy: { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations },
|
|
67
70
|
port: cfg.wsPort,
|
|
68
71
|
onLog: (m) => (0, server_2.logErr)(m),
|
|
69
72
|
onDisplacement: (d) => (0, server_2.logErr)(`SECURITY: extension connection displaced (different id: ${d.differentId})`),
|
|
@@ -54,7 +54,9 @@ export declare class CdpExecutor implements Executor {
|
|
|
54
54
|
private guard;
|
|
55
55
|
tabsList(): Promise<TabInfo[]>;
|
|
56
56
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
57
|
-
tabNew(url?: string
|
|
57
|
+
tabNew(url?: string, opts?: {
|
|
58
|
+
active?: boolean;
|
|
59
|
+
}): Promise<TabInfo>;
|
|
58
60
|
tabClose(tabId: TabId): Promise<{
|
|
59
61
|
closed: true;
|
|
60
62
|
tabId: TabId;
|
|
@@ -288,11 +288,13 @@ class CdpExecutor {
|
|
|
288
288
|
await p.bringToFront().catch(() => undefined);
|
|
289
289
|
return { tabId, url: p.url(), title: await p.title().catch(() => ''), active: true, index: 0 };
|
|
290
290
|
}
|
|
291
|
-
async tabNew(url) {
|
|
291
|
+
async tabNew(url, opts) {
|
|
292
292
|
const ctx = await this.getContext();
|
|
293
293
|
const p = await ctx.newPage();
|
|
294
294
|
if (url)
|
|
295
295
|
await p.goto(url, { waitUntil: 'load' }).catch(() => undefined);
|
|
296
|
+
if (opts?.active !== false)
|
|
297
|
+
await p.bringToFront().catch(() => undefined);
|
|
296
298
|
return { tabId: this.idFor(p), url: p.url(), title: await p.title().catch(() => ''), active: true, index: 0 };
|
|
297
299
|
}
|
|
298
300
|
async tabClose(tabId) {
|
|
@@ -20,7 +20,9 @@ export declare class ExtensionExecutor implements Executor {
|
|
|
20
20
|
dispose(): Promise<void>;
|
|
21
21
|
tabsList(): Promise<TabInfo[]>;
|
|
22
22
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
23
|
-
tabNew(url?: string
|
|
23
|
+
tabNew(url?: string, opts?: {
|
|
24
|
+
active?: boolean;
|
|
25
|
+
}): Promise<TabInfo>;
|
|
24
26
|
tabClose(tabId: TabId): Promise<{
|
|
25
27
|
closed: true;
|
|
26
28
|
tabId: TabId;
|
|
@@ -61,8 +61,8 @@ class ExtensionExecutor {
|
|
|
61
61
|
async tabSelect(tabId) {
|
|
62
62
|
return (await this.send('tab_select', {}, { tabId }));
|
|
63
63
|
}
|
|
64
|
-
async tabNew(url) {
|
|
65
|
-
return (await this.send('tab_new', { url }));
|
|
64
|
+
async tabNew(url, opts) {
|
|
65
|
+
return (await this.send('tab_new', { url, active: opts?.active }));
|
|
66
66
|
}
|
|
67
67
|
async tabClose(tabId) {
|
|
68
68
|
return (await this.send('tab_close', {}, { tabId }));
|
|
@@ -27,7 +27,9 @@ export declare class StubExecutor implements Executor {
|
|
|
27
27
|
dispose(): Promise<void>;
|
|
28
28
|
tabsList(): Promise<TabInfo[]>;
|
|
29
29
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
30
|
-
tabNew(url?: string
|
|
30
|
+
tabNew(url?: string, _opts?: {
|
|
31
|
+
active?: boolean;
|
|
32
|
+
}): Promise<TabInfo>;
|
|
31
33
|
tabClose(tabId: TabId): Promise<{
|
|
32
34
|
closed: true;
|
|
33
35
|
tabId: TabId;
|
|
@@ -144,7 +144,10 @@ export interface Executor {
|
|
|
144
144
|
dispose(): Promise<void>;
|
|
145
145
|
tabsList(): Promise<TabInfo[]>;
|
|
146
146
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
147
|
-
|
|
147
|
+
/** Open a tab. `active` (default true) focuses it; pass false to open in the background. */
|
|
148
|
+
tabNew(url?: string, opts?: {
|
|
149
|
+
active?: boolean;
|
|
150
|
+
}): Promise<TabInfo>;
|
|
148
151
|
tabClose(tabId: TabId): Promise<{
|
|
149
152
|
closed: true;
|
|
150
153
|
tabId: TabId;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/mcp/batch.ts — the `batch` fan-out tool: run many tool calls in one
|
|
3
|
+
* request, in parallel (default) or serially.
|
|
4
|
+
*
|
|
5
|
+
* Pure server-side composition: every sub-op is routed back through the same
|
|
6
|
+
* `dispatchToolCall` firewall, so it inherits the policy gate (server AND
|
|
7
|
+
* extension), the rate limiter, the executor-ready guard, and never-throw error
|
|
8
|
+
* rendering. There is no security bypass — a batch of N tool calls is exactly N
|
|
9
|
+
* ordinary tool calls that happen to be issued together.
|
|
10
|
+
*
|
|
11
|
+
* Concurrency safety: in parallel mode a tab-scoped sub-op that omits `tabId`
|
|
12
|
+
* would fall back to the shared "active tab" pointer, which races under
|
|
13
|
+
* concurrency (see docs/BLUEPRINT.md and the SW executor's active-tab default).
|
|
14
|
+
* So parallel mode REQUIRES an explicit `tabId` on tab-scoped ops; the op is
|
|
15
|
+
* rejected (as its own isError result) rather than silently mis-routed.
|
|
16
|
+
*/
|
|
17
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
18
|
+
/** Routes a single tool call through the never-throw firewall. */
|
|
19
|
+
export type DispatchFn = (name: string, args: unknown) => Promise<CallToolResult>;
|
|
20
|
+
export interface BatchDeps {
|
|
21
|
+
dispatch: DispatchFn;
|
|
22
|
+
/** True for tools that act on a specific tab and default to the active tab
|
|
23
|
+
* when `tabId` is omitted — those need an explicit `tabId` in parallel mode. */
|
|
24
|
+
requiresExplicitTab: (tool: string) => boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function runBatch(rawArgs: unknown, deps: BatchDeps): Promise<CallToolResult>;
|