@mehmoodqureshi/chrome-mcp 0.9.1 → 0.9.2
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 +40 -0
- package/dist/shared/auth-wall.d.ts +51 -0
- package/dist/shared/auth-wall.js +140 -0
- package/dist/src/config.js +7 -0
- package/dist/src/executor/stub-executor.d.ts +15 -0
- package/dist/src/executor/stub-executor.js +26 -0
- package/dist/src/executor/types.d.ts +3 -1
- package/dist/src/mcp/tools.js +121 -31
- package/dist/src/security/policy.d.ts +4 -0
- package/dist/src/security/policy.js +2 -0
- package/docs/BLUEPRINT.md +1 -1
- package/extension-dist/manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -333,6 +333,46 @@ type { "role": "textbox", "name": "Email", "text": "a@b.com" }
|
|
|
333
333
|
Resolution is server-side and refuses to guess: an ambiguous locator fails with
|
|
334
334
|
the candidates listed rather than acting on the first one (pass `nth` to pick).
|
|
335
335
|
|
|
336
|
+
### Did the session expire? — `auth_check` and `failOnAuthWall`
|
|
337
|
+
|
|
338
|
+
Reusing a signed-in Chrome removes the login step, but a session cookie can
|
|
339
|
+
still expire mid-run. Without a distinct signal the next step fails as
|
|
340
|
+
`SELECTOR_NOT_FOUND` or `TIMEOUT`, and an eval harness scores the run as an
|
|
341
|
+
agent failure when it was an auth failure. Every `snapshot` now carries an
|
|
342
|
+
`authWall` verdict when the page looks like a sign-in wall, and there is a
|
|
343
|
+
dedicated probe:
|
|
344
|
+
|
|
345
|
+
```jsonc
|
|
346
|
+
auth_check {} // { authRequired, confidence, signals }
|
|
347
|
+
auth_check { "failOnAuthWall": true } // [AUTH_REQUIRED] error instead
|
|
348
|
+
navigate { "url": "https://app.example.com/dashboard", "failOnAuthWall": true }
|
|
349
|
+
snapshot { "failOnAuthWall": true }
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
For a harness, set it once instead of per call:
|
|
353
|
+
|
|
354
|
+
```
|
|
355
|
+
npx -y @mehmoodqureshi/chrome-mcp --allow-domain app.example.com --enable-mutations --fail-on-auth-wall
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
With the flag on, every step that can move the tab (`navigate`, `click`, `type`,
|
|
359
|
+
`select_option`, `press`, `fill_form`, `back`, `forward`, `reload`) checks the
|
|
360
|
+
page it landed on and fails with `[AUTH_REQUIRED]` if that page is a sign-in
|
|
361
|
+
wall, and a `wait_for` that times out on such a page reports `[AUTH_REQUIRED]`
|
|
362
|
+
instead of `[TIMEOUT]`. Each guarded step costs one extra snapshot round-trip;
|
|
363
|
+
with the flag off the cost is zero. `[AUTH_REQUIRED]` is where a harness pauses
|
|
364
|
+
for a human to sign in again in the same Chrome, then retries the step. chrome-mcp
|
|
365
|
+
never re-authenticates on its own: it holds no credentials, by design.
|
|
366
|
+
|
|
367
|
+
Detection reads only what the snapshot already has: the URL (sign-in routes,
|
|
368
|
+
identity-provider hosts such as `accounts.google.com`, `login.microsoftonline.com`,
|
|
369
|
+
Okta, Auth0), the title, password fields, and sign-in controls. `high`
|
|
370
|
+
confidence needs two independent cues (a password field plus a sign-in button,
|
|
371
|
+
say); a lone password field or a bare `/auth/...` URL is `medium`. A header
|
|
372
|
+
"Sign in" link on an ordinary page never counts. `failOnAuthWall` fires only on
|
|
373
|
+
`high`, so a harness can bucket `[AUTH_REQUIRED]` separately from every other
|
|
374
|
+
failure while a settings page with a "current password" field carries on.
|
|
375
|
+
|
|
336
376
|
### Printing — `print_pdf`
|
|
337
377
|
|
|
338
378
|
```jsonc
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/auth-wall.ts — decide whether a page is a login wall.
|
|
3
|
+
*
|
|
4
|
+
* A browser agent that reuses a signed-in Chrome profile still hits the moment
|
|
5
|
+
* the session cookie expires mid-run: the next page is a sign-in form, and
|
|
6
|
+
* every later step fails for a reason that has nothing to do with the task.
|
|
7
|
+
* Without a distinct signal, an eval harness buckets that as SELECTOR_NOT_FOUND
|
|
8
|
+
* or TIMEOUT and the run is scored as an agent failure instead of an auth
|
|
9
|
+
* failure.
|
|
10
|
+
*
|
|
11
|
+
* This module is the pure decision. It reads only what an accessibility
|
|
12
|
+
* snapshot already carries (URL, title, password fields, button/link names), so
|
|
13
|
+
* it works identically on both backends and needs nothing new from the
|
|
14
|
+
* extension. Server-side callers attach the verdict to snapshot results, expose
|
|
15
|
+
* it as `auth_check`, and can turn it into an `AUTH_REQUIRED` error.
|
|
16
|
+
*/
|
|
17
|
+
export type AuthWallConfidence = 'high' | 'medium';
|
|
18
|
+
export type AuthWallSignal = 'password-field' | 'login-url' | 'identity-provider' | 'login-title' | 'login-button' | 'username-field';
|
|
19
|
+
export interface AuthWall {
|
|
20
|
+
detected: true;
|
|
21
|
+
confidence: AuthWallConfidence;
|
|
22
|
+
signals: AuthWallSignal[];
|
|
23
|
+
}
|
|
24
|
+
/** The subset of a snapshot the detector reads. */
|
|
25
|
+
export interface AuthWallPage {
|
|
26
|
+
url: string;
|
|
27
|
+
title: string;
|
|
28
|
+
nodes: Array<{
|
|
29
|
+
role: string;
|
|
30
|
+
name: string;
|
|
31
|
+
tag: string;
|
|
32
|
+
secret?: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Inspect a page and return a verdict, or `null` when it does not look like a
|
|
37
|
+
* login wall.
|
|
38
|
+
*
|
|
39
|
+
* Confidence:
|
|
40
|
+
* - `high`: a password field plus a sign-in control, title, or URL; or a
|
|
41
|
+
* sign-in URL plus a sign-in control or title; or an identity-provider host.
|
|
42
|
+
* - `medium`: exactly one weaker cue on its own (a password field, or a
|
|
43
|
+
* sign-in URL) with nothing to corroborate it. Callers that fail a run on a
|
|
44
|
+
* wall should usually require `high`.
|
|
45
|
+
*
|
|
46
|
+
* A lone "Sign in" link or a title mention never counts: most public pages have
|
|
47
|
+
* one in the header.
|
|
48
|
+
*/
|
|
49
|
+
export declare function detectAuthWall(page: AuthWallPage): AuthWall | null;
|
|
50
|
+
/** A one-line, human-readable explanation for error messages and logs. */
|
|
51
|
+
export declare function describeAuthWall(wall: AuthWall, url: string): string;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* shared/auth-wall.ts — decide whether a page is a login wall.
|
|
4
|
+
*
|
|
5
|
+
* A browser agent that reuses a signed-in Chrome profile still hits the moment
|
|
6
|
+
* the session cookie expires mid-run: the next page is a sign-in form, and
|
|
7
|
+
* every later step fails for a reason that has nothing to do with the task.
|
|
8
|
+
* Without a distinct signal, an eval harness buckets that as SELECTOR_NOT_FOUND
|
|
9
|
+
* or TIMEOUT and the run is scored as an agent failure instead of an auth
|
|
10
|
+
* failure.
|
|
11
|
+
*
|
|
12
|
+
* This module is the pure decision. It reads only what an accessibility
|
|
13
|
+
* snapshot already carries (URL, title, password fields, button/link names), so
|
|
14
|
+
* it works identically on both backends and needs nothing new from the
|
|
15
|
+
* extension. Server-side callers attach the verdict to snapshot results, expose
|
|
16
|
+
* it as `auth_check`, and can turn it into an `AUTH_REQUIRED` error.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.detectAuthWall = detectAuthWall;
|
|
20
|
+
exports.describeAuthWall = describeAuthWall;
|
|
21
|
+
/** Path segments that mark a sign-in route on most stacks. */
|
|
22
|
+
const LOGIN_PATH = /(^|[\/._-])(login|log-in|logon|log-on|signin|sign-in|sign_in|authenticate|auth|sso|oauth2?|saml|sessions?\/new|users\/sign_in|account\/login|idp)([\/?#._-]|$)/i;
|
|
23
|
+
/** Hosts (or host suffixes) that are identity providers, whatever the path. */
|
|
24
|
+
const IDP_HOSTS = [
|
|
25
|
+
'accounts.google.com',
|
|
26
|
+
'login.microsoftonline.com',
|
|
27
|
+
'login.live.com',
|
|
28
|
+
'login.microsoft.com',
|
|
29
|
+
'appleid.apple.com',
|
|
30
|
+
'login.yahoo.com',
|
|
31
|
+
'auth0.com',
|
|
32
|
+
'okta.com',
|
|
33
|
+
'oktapreview.com',
|
|
34
|
+
'onelogin.com',
|
|
35
|
+
'pingidentity.com',
|
|
36
|
+
'duosecurity.com',
|
|
37
|
+
'github.com/login',
|
|
38
|
+
'login.salesforce.com',
|
|
39
|
+
'signin.aws.amazon.com',
|
|
40
|
+
'auth.atlassian.com',
|
|
41
|
+
'id.atlassian.com',
|
|
42
|
+
'login.linkedin.com',
|
|
43
|
+
];
|
|
44
|
+
const LOGIN_TITLE = /\b(sign in|sign-in|signin|log in|log-in|login|log on|logon|authenticate|authentication required|session (has )?expired|session timed out|please sign in|please log in|verify it'?s you)\b/i;
|
|
45
|
+
/** Button/link labels that submit or start a sign-in. Anchored so a header "Sign in" link with extra copy still matches only when the label is the action. */
|
|
46
|
+
const LOGIN_BUTTON = /^(sign in|sign-in|signin|log in|log-in|login|log on|logon|continue|next|submit|authenticate|(sign|log) ?in (with|via|using) .{1,40}|continue with .{1,40}|use (your )?password|sign in to continue|log in to continue)$/i;
|
|
47
|
+
/** Labels that mark a credential's username half; only a booster, never a wall on its own. */
|
|
48
|
+
const USERNAME_FIELD = /\b(email|e-mail|username|user name|user id|userid|phone|account|login id)\b/i;
|
|
49
|
+
/** The action-shaped labels that, next to a password field, make this a form and not a "change password" settings page. */
|
|
50
|
+
const STRONG_BUTTON = /^(sign in|sign-in|signin|log in|log-in|login|log on|logon|authenticate|sign in to continue|log in to continue|(sign|log) ?in (with|via|using) .{1,40}|continue with .{1,40})$/i;
|
|
51
|
+
function parseUrl(url) {
|
|
52
|
+
try {
|
|
53
|
+
return new URL(url);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function isIdpHost(u) {
|
|
60
|
+
const host = u.hostname.toLowerCase();
|
|
61
|
+
const hostAndPath = `${host}${u.pathname}`.toLowerCase();
|
|
62
|
+
return IDP_HOSTS.some((h) => {
|
|
63
|
+
if (h.includes('/'))
|
|
64
|
+
return hostAndPath === h || hostAndPath.startsWith(`${h}/`);
|
|
65
|
+
return host === h || host.endsWith(`.${h}`);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Inspect a page and return a verdict, or `null` when it does not look like a
|
|
70
|
+
* login wall.
|
|
71
|
+
*
|
|
72
|
+
* Confidence:
|
|
73
|
+
* - `high`: a password field plus a sign-in control, title, or URL; or a
|
|
74
|
+
* sign-in URL plus a sign-in control or title; or an identity-provider host.
|
|
75
|
+
* - `medium`: exactly one weaker cue on its own (a password field, or a
|
|
76
|
+
* sign-in URL) with nothing to corroborate it. Callers that fail a run on a
|
|
77
|
+
* wall should usually require `high`.
|
|
78
|
+
*
|
|
79
|
+
* A lone "Sign in" link or a title mention never counts: most public pages have
|
|
80
|
+
* one in the header.
|
|
81
|
+
*/
|
|
82
|
+
function detectAuthWall(page) {
|
|
83
|
+
const signals = new Set();
|
|
84
|
+
const u = parseUrl(page.url);
|
|
85
|
+
if (u && LOGIN_PATH.test(u.pathname))
|
|
86
|
+
signals.add('login-url');
|
|
87
|
+
if (u && isIdpHost(u))
|
|
88
|
+
signals.add('identity-provider');
|
|
89
|
+
if (LOGIN_TITLE.test(page.title || ''))
|
|
90
|
+
signals.add('login-title');
|
|
91
|
+
let strongButton = false;
|
|
92
|
+
for (const n of page.nodes) {
|
|
93
|
+
if (n.secret === true || (n.tag === 'input' && n.role === 'textbox' && /password/i.test(n.name))) {
|
|
94
|
+
signals.add('password-field');
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const label = (n.name || '').trim();
|
|
98
|
+
if (!label)
|
|
99
|
+
continue;
|
|
100
|
+
const clickable = n.role === 'button' || n.role === 'link' || n.tag === 'button';
|
|
101
|
+
if (clickable && LOGIN_BUTTON.test(label)) {
|
|
102
|
+
signals.add('login-button');
|
|
103
|
+
if (STRONG_BUTTON.test(label))
|
|
104
|
+
strongButton = true;
|
|
105
|
+
}
|
|
106
|
+
else if ((n.role === 'textbox' || n.role === 'combobox') && USERNAME_FIELD.test(label)) {
|
|
107
|
+
signals.add('username-field');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const has = (s) => signals.has(s);
|
|
111
|
+
const password = has('password-field');
|
|
112
|
+
const loginUrl = has('login-url');
|
|
113
|
+
const idp = has('identity-provider');
|
|
114
|
+
const title = has('login-title');
|
|
115
|
+
let confidence = null;
|
|
116
|
+
if (idp)
|
|
117
|
+
confidence = 'high';
|
|
118
|
+
else if (password && (strongButton || title || loginUrl || has('username-field')))
|
|
119
|
+
confidence = 'high';
|
|
120
|
+
else if (loginUrl && (strongButton || title || has('username-field')))
|
|
121
|
+
confidence = 'high';
|
|
122
|
+
else if (password || loginUrl)
|
|
123
|
+
confidence = 'medium';
|
|
124
|
+
if (!confidence)
|
|
125
|
+
return null;
|
|
126
|
+
const order = [
|
|
127
|
+
'password-field',
|
|
128
|
+
'identity-provider',
|
|
129
|
+
'login-url',
|
|
130
|
+
'login-title',
|
|
131
|
+
'login-button',
|
|
132
|
+
'username-field',
|
|
133
|
+
];
|
|
134
|
+
return { detected: true, confidence, signals: order.filter(has) };
|
|
135
|
+
}
|
|
136
|
+
/** A one-line, human-readable explanation for error messages and logs. */
|
|
137
|
+
function describeAuthWall(wall, url) {
|
|
138
|
+
return `the page at ${url} looks like a sign-in wall (${wall.confidence} confidence: ${wall.signals.join(', ')}); the session has probably expired`;
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=auth-wall.js.map
|
package/dist/src/config.js
CHANGED
|
@@ -150,6 +150,9 @@ function parseArgs(argv) {
|
|
|
150
150
|
policyFlags.redact = true;
|
|
151
151
|
(policyFlags.redactPatterns ??= []).push(requireValue(argv[++i], '--redact-pattern'));
|
|
152
152
|
break;
|
|
153
|
+
case '--fail-on-auth-wall':
|
|
154
|
+
policyFlags.failOnAuthWall = true;
|
|
155
|
+
break;
|
|
153
156
|
case '--cdp-fallback':
|
|
154
157
|
cdpFallback = true;
|
|
155
158
|
break;
|
|
@@ -310,6 +313,10 @@ Security (default: deny-all safe mode):
|
|
|
310
313
|
tokens, private keys) out of page reads. Password field
|
|
311
314
|
values are always suppressed, with or without this.
|
|
312
315
|
--redact-pattern <re> Add a redaction regex (repeatable; implies --redact)
|
|
316
|
+
--fail-on-auth-wall Fail any navigate/click/type/press/back/reload/wait_for
|
|
317
|
+
with [AUTH_REQUIRED] when the page it lands on is a
|
|
318
|
+
high-confidence sign-in wall (expired session), so an
|
|
319
|
+
eval harness never scores it as some other failure.
|
|
313
320
|
|
|
314
321
|
Misc:
|
|
315
322
|
--log-level <lvl> silent | info | debug (default info)
|
|
@@ -54,6 +54,15 @@ export interface StubOptions {
|
|
|
54
54
|
snapshotNodes?: SnapshotNode[];
|
|
55
55
|
/** Frames the stub reports for `frames_list`. */
|
|
56
56
|
frames?: FrameInfo[];
|
|
57
|
+
/** Applied after any action or history move: models a click/submit/back that
|
|
58
|
+
* lands the tab on a different page (e.g. a redirect to a sign-in wall). */
|
|
59
|
+
afterAction?: {
|
|
60
|
+
url?: string;
|
|
61
|
+
nodes?: SnapshotNode[];
|
|
62
|
+
};
|
|
63
|
+
/** When true, `waitFor` rejects with TIMEOUT - the error a wait on a page
|
|
64
|
+
* that silently became a login form used to surface. */
|
|
65
|
+
waitForTimesOut?: boolean;
|
|
57
66
|
/** What the in-page observers return. Absent = the hook is not installed,
|
|
58
67
|
* which is the case the tools must report clearly rather than as an empty list. */
|
|
59
68
|
observers?: ObserverReadResult;
|
|
@@ -76,6 +85,10 @@ export declare class StubExecutor implements Executor {
|
|
|
76
85
|
snapshotNodes: SnapshotNode[];
|
|
77
86
|
private readonly frames;
|
|
78
87
|
private readonly observerState?;
|
|
88
|
+
private readonly afterAction?;
|
|
89
|
+
private readonly waitForTimesOut;
|
|
90
|
+
/** How many snapshots were taken - the auth guard must cost none when off. */
|
|
91
|
+
snapshotCalls: number;
|
|
79
92
|
/** The last observer args received, so a test can assert what was requested. */
|
|
80
93
|
lastObserverArgs?: ObserverArgs;
|
|
81
94
|
/** How many times the gate actually asked for the tab list — the round-trip
|
|
@@ -110,6 +123,8 @@ export declare class StubExecutor implements Executor {
|
|
|
110
123
|
back(): Promise<NavResult>;
|
|
111
124
|
forward(): Promise<NavResult>;
|
|
112
125
|
reload(): Promise<NavResult>;
|
|
126
|
+
/** Move the stub tab to the configured post-action page, if any. */
|
|
127
|
+
private landed;
|
|
113
128
|
click(): Promise<ActionOk>;
|
|
114
129
|
type(): Promise<ActionOk>;
|
|
115
130
|
fill(): Promise<ActionOk>;
|
|
@@ -32,6 +32,10 @@ class StubExecutor {
|
|
|
32
32
|
snapshotNodes;
|
|
33
33
|
frames;
|
|
34
34
|
observerState;
|
|
35
|
+
afterAction;
|
|
36
|
+
waitForTimesOut;
|
|
37
|
+
/** How many snapshots were taken - the auth guard must cost none when off. */
|
|
38
|
+
snapshotCalls = 0;
|
|
35
39
|
/** The last observer args received, so a test can assert what was requested. */
|
|
36
40
|
lastObserverArgs;
|
|
37
41
|
/** How many times the gate actually asked for the tab list — the round-trip
|
|
@@ -40,6 +44,8 @@ class StubExecutor {
|
|
|
40
44
|
ready = false;
|
|
41
45
|
constructor(opts = {}) {
|
|
42
46
|
this.url = opts.activeUrl ?? 'about:blank';
|
|
47
|
+
this.afterAction = opts.afterAction;
|
|
48
|
+
this.waitForTimesOut = opts.waitForTimesOut ?? false;
|
|
43
49
|
this.evalThrows = opts.evalThrows ?? false;
|
|
44
50
|
this.tabsListThrows = opts.tabsListThrows ?? false;
|
|
45
51
|
this.noTabs = opts.noTabs ?? false;
|
|
@@ -133,15 +139,28 @@ class StubExecutor {
|
|
|
133
139
|
return { url: args.url, title: 'Stub Page', httpStatus: 200 };
|
|
134
140
|
}
|
|
135
141
|
async back() {
|
|
142
|
+
this.landed();
|
|
136
143
|
return { url: this.url, title: 'Stub Page' };
|
|
137
144
|
}
|
|
138
145
|
async forward() {
|
|
146
|
+
this.landed();
|
|
139
147
|
return { url: this.url, title: 'Stub Page' };
|
|
140
148
|
}
|
|
141
149
|
async reload() {
|
|
150
|
+
this.landed();
|
|
142
151
|
return { url: this.url, title: 'Stub Page' };
|
|
143
152
|
}
|
|
153
|
+
/** Move the stub tab to the configured post-action page, if any. */
|
|
154
|
+
landed() {
|
|
155
|
+
if (!this.afterAction)
|
|
156
|
+
return;
|
|
157
|
+
if (this.afterAction.url !== undefined)
|
|
158
|
+
this.url = this.afterAction.url;
|
|
159
|
+
if (this.afterAction.nodes !== undefined)
|
|
160
|
+
this.snapshotNodes = this.afterAction.nodes;
|
|
161
|
+
}
|
|
144
162
|
async click() {
|
|
163
|
+
this.landed();
|
|
145
164
|
return ok;
|
|
146
165
|
}
|
|
147
166
|
async type() {
|
|
@@ -149,18 +168,22 @@ class StubExecutor {
|
|
|
149
168
|
this.remainingWriteDisconnects--;
|
|
150
169
|
throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'stub: service worker recycled mid-command');
|
|
151
170
|
}
|
|
171
|
+
this.landed();
|
|
152
172
|
return ok;
|
|
153
173
|
}
|
|
154
174
|
async fill() {
|
|
175
|
+
this.landed();
|
|
155
176
|
return ok;
|
|
156
177
|
}
|
|
157
178
|
async press() {
|
|
179
|
+
this.landed();
|
|
158
180
|
return ok;
|
|
159
181
|
}
|
|
160
182
|
async hover() {
|
|
161
183
|
return ok;
|
|
162
184
|
}
|
|
163
185
|
async selectOption() {
|
|
186
|
+
this.landed();
|
|
164
187
|
return ok;
|
|
165
188
|
}
|
|
166
189
|
async scroll() {
|
|
@@ -175,6 +198,7 @@ class StubExecutor {
|
|
|
175
198
|
return { html: this.htmlPayload };
|
|
176
199
|
}
|
|
177
200
|
async snapshot() {
|
|
201
|
+
this.snapshotCalls++;
|
|
178
202
|
return { url: this.url, title: 'Stub Page', nodes: this.snapshotNodes, truncated: false };
|
|
179
203
|
}
|
|
180
204
|
async getCookies() {
|
|
@@ -195,6 +219,8 @@ class StubExecutor {
|
|
|
195
219
|
return { ok: true, value: 'stub-value', type: 'string' };
|
|
196
220
|
}
|
|
197
221
|
async waitFor() {
|
|
222
|
+
if (this.waitForTimesOut)
|
|
223
|
+
throw new types_1.ExecutorError('TIMEOUT', 'stub: wait_for timed out');
|
|
198
224
|
return { matched: true, waitedMs: 0 };
|
|
199
225
|
}
|
|
200
226
|
async download(args) {
|
|
@@ -349,7 +349,9 @@ export interface Executor {
|
|
|
349
349
|
* (which only carries codes that originate inside the extension); these extra
|
|
350
350
|
* codes describe failures on the server half (no backend, launch failed, etc.).
|
|
351
351
|
*/
|
|
352
|
-
export type ExecutorErrorCodeLocal = 'NO_BACKEND' | 'EXTENSION_DISCONNECTED' | 'TIMEOUT' | 'TAB_NOT_FOUND' | 'STALE_TAB' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_FAILED' | 'LAUNCH_FAILED' | 'DETACHED' | 'TARGET_GONE' | 'POLICY_DENIED' | 'DEVTOOLS_OPEN' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'FRAME_NOT_FOUND' | 'OBSERVERS_DISABLED' | 'UNSUPPORTED' | 'BACKPRESSURE'
|
|
352
|
+
export type ExecutorErrorCodeLocal = 'NO_BACKEND' | 'EXTENSION_DISCONNECTED' | 'TIMEOUT' | 'TAB_NOT_FOUND' | 'STALE_TAB' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_FAILED' | 'LAUNCH_FAILED' | 'DETACHED' | 'TARGET_GONE' | 'POLICY_DENIED' | 'DEVTOOLS_OPEN' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'FRAME_NOT_FOUND' | 'OBSERVERS_DISABLED' | 'UNSUPPORTED' | 'BACKPRESSURE'
|
|
353
|
+
/** The page is a sign-in wall (session expired mid-run). Raised only when the caller opts in via `failOnAuthWall`. */
|
|
354
|
+
| 'AUTH_REQUIRED';
|
|
353
355
|
export declare class ExecutorError extends Error {
|
|
354
356
|
readonly code: ExecutorErrorCodeLocal;
|
|
355
357
|
constructor(code: ExecutorErrorCodeLocal, message: string);
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -29,6 +29,7 @@ const helpers_1 = require("./helpers");
|
|
|
29
29
|
const redact_1 = require("./redact");
|
|
30
30
|
const locate_1 = require("./locate");
|
|
31
31
|
const snapdiff_1 = require("./snapdiff");
|
|
32
|
+
const auth_wall_1 = require("../../shared/auth-wall");
|
|
32
33
|
const audit_1 = require("./audit");
|
|
33
34
|
const log_1 = require("./log");
|
|
34
35
|
const tasks_1 = require("../bridge/tasks");
|
|
@@ -40,6 +41,7 @@ const TARGET_PROPS = {
|
|
|
40
41
|
ref: zod_1.z.string().describe('Element ref from a prior read (exactly one of selector|ref)').optional(),
|
|
41
42
|
};
|
|
42
43
|
const tabIdField = zod_1.z.string().describe('Target tab id (defaults to the active tab)').optional();
|
|
44
|
+
const authWallField = zod_1.z.boolean().describe('Fail with [AUTH_REQUIRED] when the page this call lands on is a high-confidence sign-in wall (session expired). Off by default unless the server runs with --fail-on-auth-wall; snapshot still reports the verdict as `authWall` either way.').optional();
|
|
43
45
|
/**
|
|
44
46
|
* Frame targeting. Omitted = the top frame, which is what every call did before
|
|
45
47
|
* frames were addressable. `allFrames` is the one to reach for when a selector
|
|
@@ -79,27 +81,27 @@ exports.TOOL_DEFINITIONS = [
|
|
|
79
81
|
{ name: 'tab_select', description: 'Make a tab active by tabId.', inputSchema: { tabId: zod_1.z.string() } },
|
|
80
82
|
{ name: 'tab_new', description: 'Open a NEW tab (optionally at a URL) and focus it. Prefer this over `navigate` when the user says "open"/"go to" a site — `navigate` REPLACES the current tab. Pass active:false to open in the background (used by parallel batches).', inputSchema: { url: zod_1.z.string().optional(), active: zod_1.z.boolean().optional() } },
|
|
81
83
|
{ name: 'tab_close', description: 'Close a tab by tabId.', inputSchema: { tabId: zod_1.z.string() } },
|
|
82
|
-
{ name: 'navigate', description: 'Navigate a tab to a URL, REPLACING its current page. Acts on the active tab unless tabId is given — to open a site without losing the current page, use `tab_new` instead.', inputSchema: { url: zod_1.z.string(), tabId: tabIdField, waitUntil: waitUntilField } },
|
|
83
|
-
{ name: 'back', description: 'Go back in history.', inputSchema: { tabId: tabIdField } },
|
|
84
|
-
{ name: 'forward', description: 'Go forward in history.', inputSchema: { tabId: tabIdField } },
|
|
85
|
-
{ name: 'reload', description: 'Reload the active (or given) tab.', inputSchema: { tabId: tabIdField, waitUntil: waitUntilField } },
|
|
86
|
-
{ name: 'click', description: 'Click an element. Target by selector, a snapshot ref, or role+name (e.g. role:"button", name:"Sign in") - the locator needs no snapshot first. trusted=true uses real OS-level input.', inputSchema: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, tabId: tabIdField, button: zod_1.z.enum(['left', 'right', 'middle']).optional(), clickCount: zod_1.z.number().optional(), trusted: zod_1.z.boolean().optional(), snapshotAfter: snapshotAfterField } },
|
|
87
|
-
{ name: 'type', description: 'Type text into an element (target by selector, ref, or role+name). trusted=true sends real keystrokes (works on React/Vue controlled inputs).', inputSchema: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, text: zod_1.z.string(), tabId: tabIdField, clear: zod_1.z.boolean().optional(), pressEnter: zod_1.z.boolean().optional(), keyEvents: zod_1.z.boolean().optional(), trusted: zod_1.z.boolean().optional(), snapshotAfter: snapshotAfterField } },
|
|
88
|
-
{ name: 'select_option', description: 'Select option(s) of a <select> by value or visible label.', inputSchema: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, values: zod_1.z.array(zod_1.z.string()), tabId: tabIdField, snapshotAfter: snapshotAfterField } },
|
|
89
|
-
{ name: 'press', description: 'Press a key (with optional modifiers).', inputSchema: { key: zod_1.z.string(), modifiers: zod_1.z.array(zod_1.z.string()).optional(), tabId: tabIdField } },
|
|
84
|
+
{ name: 'navigate', description: 'Navigate a tab to a URL, REPLACING its current page. Acts on the active tab unless tabId is given — to open a site without losing the current page, use `tab_new` instead.', inputSchema: { url: zod_1.z.string(), tabId: tabIdField, waitUntil: waitUntilField, failOnAuthWall: authWallField } },
|
|
85
|
+
{ name: 'back', description: 'Go back in history.', inputSchema: { tabId: tabIdField, failOnAuthWall: authWallField } },
|
|
86
|
+
{ name: 'forward', description: 'Go forward in history.', inputSchema: { tabId: tabIdField, failOnAuthWall: authWallField } },
|
|
87
|
+
{ name: 'reload', description: 'Reload the active (or given) tab.', inputSchema: { tabId: tabIdField, waitUntil: waitUntilField, failOnAuthWall: authWallField } },
|
|
88
|
+
{ name: 'click', description: 'Click an element. Target by selector, a snapshot ref, or role+name (e.g. role:"button", name:"Sign in") - the locator needs no snapshot first. trusted=true uses real OS-level input.', inputSchema: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, tabId: tabIdField, button: zod_1.z.enum(['left', 'right', 'middle']).optional(), clickCount: zod_1.z.number().optional(), trusted: zod_1.z.boolean().optional(), snapshotAfter: snapshotAfterField, failOnAuthWall: authWallField } },
|
|
89
|
+
{ name: 'type', description: 'Type text into an element (target by selector, ref, or role+name). trusted=true sends real keystrokes (works on React/Vue controlled inputs).', inputSchema: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, text: zod_1.z.string(), tabId: tabIdField, clear: zod_1.z.boolean().optional(), pressEnter: zod_1.z.boolean().optional(), keyEvents: zod_1.z.boolean().optional(), trusted: zod_1.z.boolean().optional(), snapshotAfter: snapshotAfterField, failOnAuthWall: authWallField } },
|
|
90
|
+
{ name: 'select_option', description: 'Select option(s) of a <select> by value or visible label.', inputSchema: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, values: zod_1.z.array(zod_1.z.string()), tabId: tabIdField, snapshotAfter: snapshotAfterField, failOnAuthWall: authWallField } },
|
|
91
|
+
{ name: 'press', description: 'Press a key (with optional modifiers).', inputSchema: { key: zod_1.z.string(), modifiers: zod_1.z.array(zod_1.z.string()).optional(), tabId: tabIdField, failOnAuthWall: authWallField } },
|
|
90
92
|
{ name: 'hover', description: 'Hover over an element.', inputSchema: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, tabId: tabIdField, snapshotAfter: snapshotAfterField } },
|
|
91
93
|
{ name: 'scroll', description: 'Scroll the page or to an element.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, x: zod_1.z.number().optional(), y: zod_1.z.number().optional(), deltaX: zod_1.z.number().optional(), deltaY: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
92
94
|
{ name: 'screenshot', description: 'Capture a PNG screenshot (page or element).', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, fullPage: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
93
95
|
{ name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, tabId: tabIdField, maxBytes: maxBytesField } },
|
|
94
96
|
{ name: 'get_html', description: 'Get HTML of the page or an element. Output is capped (see maxBytes) and cut at a tag boundary; narrow it with `selector` rather than raising the cap when you can. Password field values are always blanked.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, outer: zod_1.z.boolean().optional(), tabId: tabIdField, maxBytes: maxBytesField } },
|
|
95
|
-
{ name: 'snapshot', description: 'Accessibility snapshot: interactive elements with refs to target by `ref` (more reliable than guessing CSS selectors). Pass diff:true to get only what changed since the last snapshot of this tab - far cheaper in a click/read loop. Password fields appear as secret:true with no value.', inputSchema: { interactiveOnly: zod_1.z.boolean().optional(), max: zod_1.z.number().optional(), diff: zod_1.z.boolean().describe('Return added/removed/changed elements since the previous snapshot of this tab instead of the whole tree').optional(), ...FRAME_PROPS, tabId: tabIdField } },
|
|
97
|
+
{ name: 'snapshot', description: 'Accessibility snapshot: interactive elements with refs to target by `ref` (more reliable than guessing CSS selectors). Pass diff:true to get only what changed since the last snapshot of this tab - far cheaper in a click/read loop. Password fields appear as secret:true with no value.', inputSchema: { interactiveOnly: zod_1.z.boolean().optional(), max: zod_1.z.number().optional(), diff: zod_1.z.boolean().describe('Return added/removed/changed elements since the previous snapshot of this tab instead of the whole tree').optional(), failOnAuthWall: authWallField, ...FRAME_PROPS, tabId: tabIdField } },
|
|
96
98
|
{ name: 'get_cookies', description: "Read cookies visible to the tab's URL (or a given url).", inputSchema: { url: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
97
99
|
{ name: 'storage', description: 'Read/write localStorage (or sessionStorage). op: get|set|remove|clear.', inputSchema: { op: zod_1.z.enum(['get', 'set', 'remove', 'clear']), key: zod_1.z.string().optional(), value: zod_1.z.string().optional(), session: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
98
100
|
{ name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: { expression: zod_1.z.string(), awaitPromise: zod_1.z.boolean().optional(), ...FRAME_PROPS, tabId: tabIdField } },
|
|
99
|
-
{ name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema: { selector: zod_1.z.string().optional(), textContains: zod_1.z.string().optional(), gone: zod_1.z.boolean().optional(), timeoutMs: zod_1.z.number().optional(), ...FRAME_PROPS, tabId: tabIdField } },
|
|
101
|
+
{ name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema: { selector: zod_1.z.string().optional(), textContains: zod_1.z.string().optional(), gone: zod_1.z.boolean().optional(), timeoutMs: zod_1.z.number().optional(), ...FRAME_PROPS, tabId: tabIdField, failOnAuthWall: authWallField } },
|
|
100
102
|
{ name: 'extract_links', description: 'Extract anchors from the page or a subtree. dedupe=true collapses links sharing an href (nav/footer noise); limit caps the count.', inputSchema: { selector: zod_1.z.string().optional(), sameOriginOnly: zod_1.z.boolean().optional(), dedupe: zod_1.z.boolean().optional(), limit: zod_1.z.number().optional(), ...FRAME_PROPS, tabId: tabIdField } },
|
|
101
103
|
{ name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema: { selector: zod_1.z.string().optional(), ...FRAME_PROPS, tabId: tabIdField, maxBytes: maxBytesField } },
|
|
102
|
-
{ name: 'fill_form', description: 'Fill multiple fields (keyed by selector) and optionally submit.', inputSchema: { fields: zod_1.z.record(zod_1.z.string(), zod_1.z.union([zod_1.z.string(), zod_1.z.boolean()])), submitSelector: zod_1.z.string().optional(), ...FRAME_PROPS, tabId: tabIdField } },
|
|
104
|
+
{ name: 'fill_form', description: 'Fill multiple fields (keyed by selector) and optionally submit.', inputSchema: { fields: zod_1.z.record(zod_1.z.string(), zod_1.z.union([zod_1.z.string(), zod_1.z.boolean()])), submitSelector: zod_1.z.string().optional(), ...FRAME_PROPS, tabId: tabIdField, failOnAuthWall: authWallField } },
|
|
103
105
|
{ name: 'download_file', description: 'Download a file by URL or from a link element.', inputSchema: { url: zod_1.z.string().optional(), ...TARGET_PROPS, suggestedName: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
104
106
|
{ name: 'upload_file', description: 'Set local file(s) on a file <input> (target by selector or ref) — uploads without the OS dialog. Requires --enable-uploads. `files` are absolute local paths.', inputSchema: { ...TARGET_PROPS, files: zod_1.z.array(zod_1.z.string()), tabId: tabIdField } },
|
|
105
107
|
{
|
|
@@ -161,6 +163,7 @@ exports.TOOL_DEFINITIONS = [
|
|
|
161
163
|
},
|
|
162
164
|
},
|
|
163
165
|
{ name: 'chrome_status', description: 'Report backend/session status.', inputSchema: {} },
|
|
166
|
+
{ name: 'auth_check', description: 'Is the tab sitting on a sign-in wall? Reads the page (URL, title, password fields, sign-in controls) and returns { authRequired, confidence, signals }. Use it after a navigate, or whenever a step fails unexpectedly, to tell "the session expired" apart from "the agent got lost". Pass failOnAuthWall:true to get an [AUTH_REQUIRED] error instead of a verdict, so a harness can bucket the run as an auth failure.', inputSchema: { failOnAuthWall: authWallField, ...FRAME_PROPS, tabId: tabIdField } },
|
|
164
167
|
{ name: 'profile_use', description: 'Switch the active browser profile (identity). Subsequent downloads, results, screenshots, and the action log are stored under profiles/<name>/. Resets the active task to "default" unless you then call task_new.', inputSchema: { name: zod_1.z.string().describe('Profile name (becomes a folder; sanitized to a safe path segment).') } },
|
|
165
168
|
{ name: 'task_new', description: 'Start a new task (run) under the active profile. Creates profiles/<profile>/tasks/<name>/ with downloads/, results/, screenshots/ and makes it the active task so all captured artifacts land there.', inputSchema: { name: zod_1.z.string().describe('Task name (becomes a folder; sanitized to a safe path segment).') } },
|
|
166
169
|
{ name: 'tasks_list', description: 'List every task across all profiles under the data dir, with sizes and download counts.', inputSchema: {} },
|
|
@@ -317,6 +320,51 @@ function redaction(policy) {
|
|
|
317
320
|
return cfg;
|
|
318
321
|
}
|
|
319
322
|
/** The scope a tab's remembered snapshot lives under. */
|
|
323
|
+
/**
|
|
324
|
+
* Throw `[AUTH_REQUIRED]` when the caller asked for it and the page is a
|
|
325
|
+
* high-confidence sign-in wall. Medium-confidence verdicts never fail a call: a
|
|
326
|
+
* lone password field on an otherwise ordinary page is not worth aborting over.
|
|
327
|
+
*/
|
|
328
|
+
function failIfAuthWall(wall, url, a, policy) {
|
|
329
|
+
if (!wall || wall.confidence !== 'high')
|
|
330
|
+
return;
|
|
331
|
+
if (!authGuardOn(a, policy))
|
|
332
|
+
return;
|
|
333
|
+
throw new types_1.ExecutorError('AUTH_REQUIRED', (0, auth_wall_1.describeAuthWall)(wall, url));
|
|
334
|
+
}
|
|
335
|
+
/** The guard is on for this call when the caller asked, or the server runs with `--fail-on-auth-wall`. */
|
|
336
|
+
function authGuardOn(a, policy) {
|
|
337
|
+
return (0, validators_1.optionalBoolean)(a, 'failOnAuthWall') === true || policy.failOnAuthWall === true;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* After an action or history move: when the guard is on, look at the page the
|
|
341
|
+
* tab landed on and fail with `AUTH_REQUIRED` if it is a sign-in wall. Costs
|
|
342
|
+
* one snapshot round-trip, and only when the guard is on. The snapshot is
|
|
343
|
+
* remembered for this tab so a later `snapshot { diff: true }` stays coherent.
|
|
344
|
+
*/
|
|
345
|
+
async function guardAuthWall(ctx, a) {
|
|
346
|
+
if (!authGuardOn(a, ctx.policy))
|
|
347
|
+
return;
|
|
348
|
+
const snap = await ctx.ex.snapshot({ tabId: tabId(a), interactiveOnly: true, max: 200 });
|
|
349
|
+
(0, snapdiff_1.rememberSnapshot)(snapScope(a), snap);
|
|
350
|
+
failIfAuthWall((0, auth_wall_1.detectAuthWall)(snap), snap.url, a, ctx.policy);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* A wait that timed out on a page that has become a sign-in wall is an auth
|
|
354
|
+
* failure, not a slow page. With the guard on, reclassify it.
|
|
355
|
+
*/
|
|
356
|
+
async function reclassifyTimeout(ctx, a, err) {
|
|
357
|
+
if (err instanceof types_1.ExecutorError && err.code === 'TIMEOUT' && authGuardOn(a, ctx.policy)) {
|
|
358
|
+
await guardAuthWall(ctx, a);
|
|
359
|
+
}
|
|
360
|
+
throw err;
|
|
361
|
+
}
|
|
362
|
+
/** The `authWall` field to spread onto a page-read result: present only when detected. */
|
|
363
|
+
function authWallOf(ctx, snap, a) {
|
|
364
|
+
const wall = (0, auth_wall_1.detectAuthWall)(snap);
|
|
365
|
+
failIfAuthWall(wall, snap.url, a, ctx.policy);
|
|
366
|
+
return wall ? { authWall: wall } : {};
|
|
367
|
+
}
|
|
320
368
|
const snapScope = (a) => (0, snapdiff_1.scopeOf)((0, workspace_1.peekActiveWorkspace)()?.profile ?? 'default', tabId(a));
|
|
321
369
|
/**
|
|
322
370
|
* Render an action's result, optionally with what the action CHANGED on the
|
|
@@ -324,13 +372,20 @@ const snapScope = (a) => (0, snapdiff_1.scopeOf)((0, workspace_1.peekActiveWorks
|
|
|
324
372
|
* re-read, which is the expensive half of every click-then-look loop.
|
|
325
373
|
*/
|
|
326
374
|
async function actionResult(ctx, a, payload) {
|
|
327
|
-
|
|
375
|
+
const wantDiff = (0, validators_1.optionalBoolean)(a, 'snapshotAfter') === true;
|
|
376
|
+
const guard = authGuardOn(a, ctx.policy);
|
|
377
|
+
if (!wantDiff && !guard)
|
|
328
378
|
return (0, envelopes_1.jsonResult)(payload);
|
|
329
379
|
const scope = snapScope(a);
|
|
330
380
|
const previous = (0, snapdiff_1.lastSnapshot)(scope);
|
|
331
381
|
const snap = await ctx.ex.snapshot({ tabId: tabId(a), max: 200, ...frameOpts(a) });
|
|
332
|
-
|
|
382
|
+
// One snapshot serves both: the auth guard reads it first (and aborts the
|
|
383
|
+
// call if the action landed on a sign-in wall), then the diff is built.
|
|
384
|
+
failIfAuthWall((0, auth_wall_1.detectAuthWall)(snap), snap.url, a, ctx.policy);
|
|
333
385
|
const stored = (0, snapdiff_1.rememberSnapshot)(scope, snap);
|
|
386
|
+
if (!wantDiff)
|
|
387
|
+
return (0, envelopes_1.jsonResult)(payload);
|
|
388
|
+
const diff = (0, snapdiff_1.diffSnapshots)(previous, snap);
|
|
334
389
|
return (0, envelopes_1.jsonResult)({ ...payload, changed: { ...diff, snapshotId: stored.id, url: snap.url } });
|
|
335
390
|
}
|
|
336
391
|
/**
|
|
@@ -403,19 +458,31 @@ exports.TOOL_HANDLERS = {
|
|
|
403
458
|
navigate: async (a, ctx) => {
|
|
404
459
|
const url = (0, validators_1.requireString)(a, 'url');
|
|
405
460
|
await gate(ctx, 'navigate', { url });
|
|
406
|
-
|
|
461
|
+
const nav = await ctx.ex.navigate({ url, tabId: tabId(a), waitUntil: waitUntil(a) });
|
|
462
|
+
if (!authGuardOn(a, ctx.policy))
|
|
463
|
+
return (0, envelopes_1.jsonResult)(nav);
|
|
464
|
+
// Guard on: the check costs one snapshot round-trip after the navigation.
|
|
465
|
+
const snap = await ctx.ex.snapshot({ tabId: tabId(a), interactiveOnly: true, max: 200 });
|
|
466
|
+
(0, snapdiff_1.rememberSnapshot)(snapScope(a), snap);
|
|
467
|
+
return (0, envelopes_1.jsonResult)({ ...nav, ...authWallOf(ctx, snap, a) });
|
|
407
468
|
},
|
|
408
469
|
back: async (a, ctx) => {
|
|
409
470
|
await gate(ctx, 'back', { tabId: tabId(a) });
|
|
410
|
-
|
|
471
|
+
const res = await ctx.ex.back(tabId(a));
|
|
472
|
+
await guardAuthWall(ctx, a);
|
|
473
|
+
return (0, envelopes_1.jsonResult)(res);
|
|
411
474
|
},
|
|
412
475
|
forward: async (a, ctx) => {
|
|
413
476
|
await gate(ctx, 'forward', { tabId: tabId(a) });
|
|
414
|
-
|
|
477
|
+
const res = await ctx.ex.forward(tabId(a));
|
|
478
|
+
await guardAuthWall(ctx, a);
|
|
479
|
+
return (0, envelopes_1.jsonResult)(res);
|
|
415
480
|
},
|
|
416
481
|
reload: async (a, ctx) => {
|
|
417
482
|
await gate(ctx, 'reload', { tabId: tabId(a) });
|
|
418
|
-
|
|
483
|
+
const res = await ctx.ex.reload({ tabId: tabId(a), waitUntil: waitUntil(a) });
|
|
484
|
+
await guardAuthWall(ctx, a);
|
|
485
|
+
return (0, envelopes_1.jsonResult)(res);
|
|
419
486
|
},
|
|
420
487
|
click: async (a, ctx) => {
|
|
421
488
|
await gate(ctx, 'click', { tabId: tabId(a) });
|
|
@@ -453,10 +520,12 @@ exports.TOOL_HANDLERS = {
|
|
|
453
520
|
},
|
|
454
521
|
press: async (a, ctx) => {
|
|
455
522
|
await gate(ctx, 'press', { tabId: tabId(a) });
|
|
456
|
-
|
|
523
|
+
const res = await ctx.ex.press((0, validators_1.requireString)(a, 'key'), {
|
|
457
524
|
tabId: tabId(a),
|
|
458
525
|
modifiers: (0, validators_1.optionalStringArray)(a, 'modifiers'),
|
|
459
|
-
})
|
|
526
|
+
});
|
|
527
|
+
await guardAuthWall(ctx, a); // Enter on a form is the classic way to land on a wall
|
|
528
|
+
return (0, envelopes_1.jsonResult)(res);
|
|
460
529
|
},
|
|
461
530
|
hover: async (a, ctx) => {
|
|
462
531
|
await gate(ctx, 'hover', { tabId: tabId(a) });
|
|
@@ -536,14 +605,16 @@ exports.TOOL_HANDLERS = {
|
|
|
536
605
|
const scope = snapScope(a);
|
|
537
606
|
const previous = (0, snapdiff_1.lastSnapshot)(scope);
|
|
538
607
|
const stored = (0, snapdiff_1.rememberSnapshot)(scope, snap);
|
|
608
|
+
const authWall = authWallOf(ctx, snap, a);
|
|
539
609
|
if ((0, validators_1.optionalBoolean)(a, 'diff') !== true) {
|
|
540
|
-
return (0, envelopes_1.jsonResult)({ ...snap, snapshotId: stored.id });
|
|
610
|
+
return (0, envelopes_1.jsonResult)({ ...snap, snapshotId: stored.id, ...authWall });
|
|
541
611
|
}
|
|
542
612
|
const diff = (0, snapdiff_1.diffSnapshots)(previous, snap);
|
|
543
613
|
return (0, envelopes_1.jsonResult)({
|
|
544
614
|
url: snap.url,
|
|
545
615
|
title: snap.title,
|
|
546
616
|
snapshotId: stored.id,
|
|
617
|
+
...authWall,
|
|
547
618
|
...diff,
|
|
548
619
|
...(diff.since === null
|
|
549
620
|
? { note: 'no previous snapshot for this tab, so everything is reported as added' }
|
|
@@ -593,14 +664,19 @@ exports.TOOL_HANDLERS = {
|
|
|
593
664
|
},
|
|
594
665
|
wait_for: async (a, ctx) => {
|
|
595
666
|
await gate(ctx, 'wait_for', { tabId: tabId(a) });
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
667
|
+
try {
|
|
668
|
+
return (0, envelopes_1.jsonResult)(await ctx.ex.waitFor({
|
|
669
|
+
tabId: tabId(a),
|
|
670
|
+
...frameOpts(a),
|
|
671
|
+
selector: (0, validators_1.optionalString)(a, 'selector'),
|
|
672
|
+
textContains: (0, validators_1.optionalString)(a, 'textContains'),
|
|
673
|
+
gone: (0, validators_1.optionalBoolean)(a, 'gone'),
|
|
674
|
+
timeoutMs: (0, validators_1.optionalNumber)(a, 'timeoutMs', { min: 0, max: 120_000 }),
|
|
675
|
+
}));
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
return reclassifyTimeout(ctx, a, err);
|
|
679
|
+
}
|
|
604
680
|
},
|
|
605
681
|
extract_links: async (a, ctx) => {
|
|
606
682
|
await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page content
|
|
@@ -643,12 +719,14 @@ exports.TOOL_HANDLERS = {
|
|
|
643
719
|
if (typeof val === 'string')
|
|
644
720
|
(0, validators_1.requireWithinLength)(val, `fields["${sel}"]`, validators_1.MAX_TEXT_LEN);
|
|
645
721
|
}
|
|
646
|
-
|
|
722
|
+
const res = await (0, helpers_1.fillForm)(ctx.ex, {
|
|
647
723
|
...frameOpts(a),
|
|
648
724
|
fields: fields,
|
|
649
725
|
submitSelector: (0, validators_1.optionalString)(a, 'submitSelector'),
|
|
650
726
|
tabId: tabId(a),
|
|
651
|
-
})
|
|
727
|
+
});
|
|
728
|
+
await guardAuthWall(ctx, a);
|
|
729
|
+
return (0, envelopes_1.jsonResult)(res);
|
|
652
730
|
},
|
|
653
731
|
download_file: async (a, ctx) => {
|
|
654
732
|
await gate(ctx, 'download_file');
|
|
@@ -768,6 +846,18 @@ exports.TOOL_HANDLERS = {
|
|
|
768
846
|
});
|
|
769
847
|
},
|
|
770
848
|
chrome_status: async (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
|
|
849
|
+
auth_check: async (a, ctx) => {
|
|
850
|
+
await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page structure
|
|
851
|
+
const snap = await ctx.ex.snapshot({ tabId: tabId(a), ...frameOpts(a), interactiveOnly: true, max: 200 });
|
|
852
|
+
const wall = (0, auth_wall_1.detectAuthWall)(snap);
|
|
853
|
+
failIfAuthWall(wall, snap.url, a, ctx.policy);
|
|
854
|
+
return (0, envelopes_1.jsonResult)({
|
|
855
|
+
url: snap.url,
|
|
856
|
+
title: snap.title,
|
|
857
|
+
authRequired: wall !== null,
|
|
858
|
+
...(wall ? { confidence: wall.confidence, signals: wall.signals } : {}),
|
|
859
|
+
});
|
|
860
|
+
},
|
|
771
861
|
// --- task workspace management (server-side; no browser needed) ---
|
|
772
862
|
profile_use: async (a) => {
|
|
773
863
|
// Snapshots are keyed by profile+tab, and a profile switch routes to a
|
|
@@ -877,7 +967,7 @@ function recordHistory(tool, rawArgs, ok, extra = {}) {
|
|
|
877
967
|
*/
|
|
878
968
|
const RETRY_SAFE_TOOLS = new Set([
|
|
879
969
|
'tabs_list', 'chrome_status',
|
|
880
|
-
'get_text', 'get_html', 'snapshot', 'get_cookies',
|
|
970
|
+
'get_text', 'get_html', 'snapshot', 'get_cookies', 'auth_check',
|
|
881
971
|
'extract_links', 'read_as_markdown', 'screenshot',
|
|
882
972
|
'wait_for', 'navigate', 'reload',
|
|
883
973
|
'frames_list', 'print_pdf',
|
|
@@ -32,6 +32,10 @@ export interface Policy extends WirePolicy {
|
|
|
32
32
|
redact?: boolean;
|
|
33
33
|
/** Extra redaction patterns (regex sources) supplied by the operator. */
|
|
34
34
|
redactPatterns?: string[];
|
|
35
|
+
/** `--fail-on-auth-wall`: every navigating tool and action fails with
|
|
36
|
+
* `AUTH_REQUIRED` when the page it lands on is a high-confidence sign-in
|
|
37
|
+
* wall, so an expired session is never scored as some other failure. */
|
|
38
|
+
failOnAuthWall?: boolean;
|
|
35
39
|
}
|
|
36
40
|
/** The SAFE default: deny everything until the user opts in. */
|
|
37
41
|
export declare const DEFAULT_POLICY: Readonly<Policy>;
|
|
@@ -43,6 +43,7 @@ exports.DEFAULT_POLICY = Object.freeze({
|
|
|
43
43
|
allowObservers: false,
|
|
44
44
|
redact: false,
|
|
45
45
|
redactPatterns: [],
|
|
46
|
+
failOnAuthWall: false,
|
|
46
47
|
});
|
|
47
48
|
/** Merge a partial (from a policy file and/or CLI flags) over the safe default. */
|
|
48
49
|
function resolvePolicy(partial) {
|
|
@@ -57,6 +58,7 @@ function resolvePolicy(partial) {
|
|
|
57
58
|
allowObservers: partial?.allowObservers ?? exports.DEFAULT_POLICY.allowObservers,
|
|
58
59
|
redact: partial?.redact ?? exports.DEFAULT_POLICY.redact,
|
|
59
60
|
redactPatterns: partial?.redactPatterns ?? [...(exports.DEFAULT_POLICY.redactPatterns ?? [])],
|
|
61
|
+
failOnAuthWall: partial?.failOnAuthWall ?? exports.DEFAULT_POLICY.failOnAuthWall,
|
|
60
62
|
};
|
|
61
63
|
}
|
|
62
64
|
// ---------------------------------------------------------------------------
|
package/docs/BLUEPRINT.md
CHANGED
|
@@ -350,7 +350,7 @@ export type ExecutorErrorCode =
|
|
|
350
350
|
|
|
351
351
|
## 5. The Complete MCP Tool Surface
|
|
352
352
|
|
|
353
|
-
|
|
353
|
+
39 tools. `readOnly` is metadata (not a JSON-Schema field) consumed by the host
|
|
354
354
|
and by **safe-mode** (shipped in v1, default ON). Every handler:
|
|
355
355
|
`withReadyExecutor()` → validate args (`requireTarget` for selector|ref) →
|
|
356
356
|
**`policy.assertUrlAllowed(currentTabUrl, method)`** → call executor/helper →
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "MCP Extension for Chrome",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.2",
|
|
5
5
|
"description": "Lets a local chrome-mcp server drive this browser. Pair it with the server's handshake token.",
|
|
6
6
|
"homepage_url": "https://github.com/Mehmoodqureshi/chrome-mcp",
|
|
7
7
|
"minimum_chrome_version": "116",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehmoodqureshi/chrome-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Drive your real Chrome browser over MCP — real logins, real cookies. A stdio MCP server (CLI) plus an MV3 extension, driving Chrome via chrome.scripting/chrome.tabs. Multi-tab batch automation, accessibility snapshots, deny-all security by default.",
|
|
5
5
|
"author": "Mehmood Ur Rehman Qureshi",
|
|
6
6
|
"license": "MIT",
|