dsh-browser-playwright-codex 0.7.4 → 0.7.6
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/CHANGELOG.md +60 -0
- package/README.md +10 -10
- package/SECURITY.md +1 -1
- package/lib/browser-lifecycle.d.ts +13 -1
- package/lib/browser-lifecycle.js +49 -5
- package/lib/errors.d.ts +12 -0
- package/lib/errors.js +16 -0
- package/lib/gates-patch.js +0 -1
- package/lib/index.d.ts +6 -3
- package/lib/index.js +4 -2
- package/lib/legacy-config.d.ts +93 -0
- package/lib/legacy-config.js +238 -0
- package/lib/navigation-guard.d.ts +48 -0
- package/lib/navigation-guard.js +48 -0
- package/lib/page-actions.js +6 -0
- package/lib/page-snapshot.js +5 -0
- package/lib/playwright.d.ts +1 -1
- package/lib/playwright.js +3 -1
- package/lib/plugin.js +5 -0
- package/lib/provider.d.ts +42 -0
- package/lib/provider.js +80 -1
- package/lib/session.d.ts +40 -4
- package/lib/session.js +95 -5
- package/lib/settings-card.js +15 -4
- package/lib/tool.js +79 -54
- package/lib/types.d.ts +28 -0
- package/lib/url-policy.d.ts +13 -0
- package/lib/url-policy.js +24 -2
- package/lib/wait-conditions.d.ts +55 -0
- package/lib/wait-conditions.js +141 -0
- package/package.json +1 -1
- package/scripts/dsh-browser-verify.mjs +34 -1
package/lib/session.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { BrowserContext, CDPSession, Locator, Page, Request } from 'playwright-core';
|
|
7
7
|
import type { PlaywrightProvider } from './provider.ts';
|
|
8
|
-
import type { BrowserNode, BrowserSnapshot, DiagnosticsEntry, DocumentResponse, LoadState } from './types.ts';
|
|
8
|
+
import type { BrowserNode, BrowserSnapshot, DiagnosticsEntry, DocumentResponse, LoadState, WaitCondition, WaitOutcome } from './types.ts';
|
|
9
9
|
import type { RefSignature } from './page-snapshot.ts';
|
|
10
10
|
import { type CookieRow } from './login-sites.ts';
|
|
11
11
|
import type { ActionResult, PendingDialogRecord } from './provider.ts';
|
|
@@ -60,6 +60,32 @@ export declare class PlaywrightSession {
|
|
|
60
60
|
/** Nonce of the baseline ref space; a change means navigation reset. */
|
|
61
61
|
lastRefNonce: string | undefined;
|
|
62
62
|
constructor(provider: PlaywrightProvider, owner: string, context: BrowserContext);
|
|
63
|
+
/**
|
|
64
|
+
* Turn a navigation the host guard refused into a named refusal.
|
|
65
|
+
*
|
|
66
|
+
* A page can navigate itself (location.href, meta refresh, a form submit, a
|
|
67
|
+
* 302) without passing any entry-point check. The guard stops the document
|
|
68
|
+
* request; this reports it, so "I clicked and nothing happened" becomes
|
|
69
|
+
* URL_NOT_ALLOWED naming the host. Consumed once: a later action must not
|
|
70
|
+
* inherit an earlier refusal.
|
|
71
|
+
*/
|
|
72
|
+
throwIfNavigationBlocked(): void;
|
|
73
|
+
/**
|
|
74
|
+
* Refuse to go on driving a page that is outside the allow-list.
|
|
75
|
+
*
|
|
76
|
+
* The request-level guard (navigation-guard.ts) catches navigations started
|
|
77
|
+
* from inside the page, but NOT a redirect the browser follows on its own:
|
|
78
|
+
* measured 2026-10-26, `context.route()` never sees the hop after a
|
|
79
|
+
* `continue()`d request, so `127.0.0.1/x` answering `302 → localhost/y` lands
|
|
80
|
+
* on the disallowed host with no route event at all. So every action that
|
|
81
|
+
* settles a page also checks where it actually ended up.
|
|
82
|
+
*
|
|
83
|
+
* The off-list page is abandoned for about:blank (hostless, always allowed)
|
|
84
|
+
* instead of being left there: otherwise the session would wedge on a page it
|
|
85
|
+
* may never drive, and "navigate somewhere allowed" could not fix it.
|
|
86
|
+
* @param page - the page this action ran on.
|
|
87
|
+
*/
|
|
88
|
+
assertCurrentUrlAllowed(page: Page): Promise<void>;
|
|
63
89
|
/**
|
|
64
90
|
* The session's cookies, reduced to what the login inference needs. `value` is dropped here,
|
|
65
91
|
* at the only place cookies enter the summary path, so nothing downstream can leak it.
|
|
@@ -248,6 +274,12 @@ export declare class PlaywrightSession {
|
|
|
248
274
|
back(signal?: AbortSignal): Promise<BrowserSnapshot>;
|
|
249
275
|
forward(signal?: AbortSignal): Promise<BrowserSnapshot>;
|
|
250
276
|
wait(ms: number, signal?: AbortSignal): Promise<void>;
|
|
277
|
+
/**
|
|
278
|
+
* Wait for a goal instead of a duration: an element, a piece of text, or a
|
|
279
|
+
* quiet network. Validated here as well, so a programmatic caller gets the
|
|
280
|
+
* same refusals the tool layer gives instead of a raw Playwright timeout.
|
|
281
|
+
*/
|
|
282
|
+
waitForCondition(condition: WaitCondition, signal?: AbortSignal): Promise<WaitOutcome>;
|
|
251
283
|
tabs(): Promise<{
|
|
252
284
|
index: number;
|
|
253
285
|
url: string;
|
|
@@ -278,9 +310,13 @@ export declare class PlaywrightSession {
|
|
|
278
310
|
* Send one raw CDP command on the current page's session and return its
|
|
279
311
|
* result. This is the "direct protocol" escape hatch behind every other
|
|
280
312
|
* tool — the model can inspect geometry, dispatch trusted input events or
|
|
281
|
-
* query the DOM the same way Chrome DevTools does.
|
|
282
|
-
*
|
|
283
|
-
*
|
|
313
|
+
* query the DOM the same way Chrome DevTools does.
|
|
314
|
+
*
|
|
315
|
+
* Both rules live here as well as in the tool layer, deliberately: the
|
|
316
|
+
* allow-list and the switch must hold for anything that reaches this method,
|
|
317
|
+
* not just for the registered tool. Before 2026-10-26 only `browser_cdp`
|
|
318
|
+
* checked them, so a caller working through the service could send
|
|
319
|
+
* `Runtime.evaluate` — arbitrary JavaScript — with the switch off (measured).
|
|
284
320
|
*/
|
|
285
321
|
cdp(method: string, params: Record<string, unknown>, signal?: AbortSignal): Promise<unknown>;
|
|
286
322
|
close(): Promise<void>;
|
package/lib/session.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { looksLikeToken, registrableDomain } from "./login-sites.js";
|
|
2
|
-
import { BrowserError } from "./errors.js";
|
|
2
|
+
import { BrowserError, cdpDisabled, evaluateDisabled } from "./errors.js";
|
|
3
|
+
import { isCdpMethodAllowed, cdpDenialMessage } from "./cdp-policy.js";
|
|
4
|
+
import { getGates } from "./runtime-state.js";
|
|
3
5
|
import { asTimeoutError, isAbortError, isContextDestroyed, isCrashError } from "./page-diagnostics.js";
|
|
4
6
|
import { consoleMessages, networkRequests, pageData, trackPage } from "./page-diagnostics.js";
|
|
5
7
|
import { elementStabilityProbe, pageStabilityProbe } from "./page-probes.js";
|
|
6
8
|
import { assertActionable, back, clickAt, clickAtRef, clickLocator, clickText, closeTab, fill, forward, hoverLocator, hoverText, navigate, openTab, press, resolveClickPoint, runAction, scroll, switchTab, verifyFill, withAbort, } from "./page-actions.js";
|
|
7
9
|
import { assertLive, attachDiff, attachDocResponse, attachNoProgress, buildRefSignatures, captureAria, captureLegacy, refLocator, resolveSnapshotEngine, settleAndSnapshot, snapshot, } from "./page-snapshot.js";
|
|
8
|
-
import { assertAllowedUrl } from "./url-policy.js";
|
|
10
|
+
import { assertAllowedUrl, isHostAllowed } from "./url-policy.js";
|
|
11
|
+
import { normalizeWaitCondition, SESSION_WAIT_CAP_MS, waitForCondition as runWait } from "./wait-conditions.js";
|
|
9
12
|
/** Live session over one browser context owned by one caller. */
|
|
10
13
|
export class PlaywrightSession {
|
|
11
14
|
provider;
|
|
@@ -55,6 +58,66 @@ export class PlaywrightSession {
|
|
|
55
58
|
this.owner = owner;
|
|
56
59
|
this.context = context;
|
|
57
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Turn a navigation the host guard refused into a named refusal.
|
|
63
|
+
*
|
|
64
|
+
* A page can navigate itself (location.href, meta refresh, a form submit, a
|
|
65
|
+
* 302) without passing any entry-point check. The guard stops the document
|
|
66
|
+
* request; this reports it, so "I clicked and nothing happened" becomes
|
|
67
|
+
* URL_NOT_ALLOWED naming the host. Consumed once: a later action must not
|
|
68
|
+
* inherit an earlier refusal.
|
|
69
|
+
*/
|
|
70
|
+
throwIfNavigationBlocked() {
|
|
71
|
+
const refused = this.provider.takeBlockedNavigation();
|
|
72
|
+
if (refused === undefined)
|
|
73
|
+
return;
|
|
74
|
+
throw new BrowserError('URL_NOT_ALLOWED', 'the page tried to navigate to ' +
|
|
75
|
+
refused.url +
|
|
76
|
+
' and was blocked: host ' +
|
|
77
|
+
refused.host +
|
|
78
|
+
' is not in allowedDomains. Nothing left this site — the request was stopped before it reached the network. ' +
|
|
79
|
+
'The page you were on is unchanged; tell the user the site tried to leave the allow-list.');
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Refuse to go on driving a page that is outside the allow-list.
|
|
83
|
+
*
|
|
84
|
+
* The request-level guard (navigation-guard.ts) catches navigations started
|
|
85
|
+
* from inside the page, but NOT a redirect the browser follows on its own:
|
|
86
|
+
* measured 2026-10-26, `context.route()` never sees the hop after a
|
|
87
|
+
* `continue()`d request, so `127.0.0.1/x` answering `302 → localhost/y` lands
|
|
88
|
+
* on the disallowed host with no route event at all. So every action that
|
|
89
|
+
* settles a page also checks where it actually ended up.
|
|
90
|
+
*
|
|
91
|
+
* The off-list page is abandoned for about:blank (hostless, always allowed)
|
|
92
|
+
* instead of being left there: otherwise the session would wedge on a page it
|
|
93
|
+
* may never drive, and "navigate somewhere allowed" could not fix it.
|
|
94
|
+
* @param page - the page this action ran on.
|
|
95
|
+
*/
|
|
96
|
+
async assertCurrentUrlAllowed(page) {
|
|
97
|
+
const raw = page.url();
|
|
98
|
+
if (raw === '')
|
|
99
|
+
return;
|
|
100
|
+
let target;
|
|
101
|
+
try {
|
|
102
|
+
target = new URL(raw);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:')
|
|
108
|
+
return;
|
|
109
|
+
const allowedDomains = this.provider.config.allowedDomains ?? [];
|
|
110
|
+
if (isHostAllowed(target.hostname, allowedDomains))
|
|
111
|
+
return;
|
|
112
|
+
await page.goto('about:blank').catch(() => { });
|
|
113
|
+
throw new BrowserError('URL_NOT_ALLOWED', 'the page ended up on host ' +
|
|
114
|
+
target.hostname +
|
|
115
|
+
' (' +
|
|
116
|
+
raw +
|
|
117
|
+
'), which is not in allowedDomains — a redirect the browser followed on its own. ' +
|
|
118
|
+
'The tab has been reset to about:blank, so nothing outside the allow-list stays reachable. ' +
|
|
119
|
+
'Tell the user: the site tried to send this session off the allow-list.');
|
|
120
|
+
}
|
|
58
121
|
/**
|
|
59
122
|
* The session's cookies, reduced to what the login inference needs. `value` is dropped here,
|
|
60
123
|
* at the only place cookies enter the summary path, so nothing downstream can leak it.
|
|
@@ -120,6 +183,9 @@ export class PlaywrightSession {
|
|
|
120
183
|
}
|
|
121
184
|
/** Run one operation under busy tracking: idle disposal defers until it settles. */
|
|
122
185
|
run(operation) {
|
|
186
|
+
// Every action starts from a clean slate: a refusal recorded during an
|
|
187
|
+
// earlier action must not be reported as this one's outcome.
|
|
188
|
+
this.provider.blockedNavigation = undefined;
|
|
123
189
|
this.provider.beginOp(this.owner);
|
|
124
190
|
return Promise.resolve()
|
|
125
191
|
.then(operation)
|
|
@@ -431,6 +497,14 @@ export class PlaywrightSession {
|
|
|
431
497
|
await withAbort(new Promise((resolve) => setTimeout(resolve, ms)), signal);
|
|
432
498
|
});
|
|
433
499
|
}
|
|
500
|
+
/**
|
|
501
|
+
* Wait for a goal instead of a duration: an element, a piece of text, or a
|
|
502
|
+
* quiet network. Validated here as well, so a programmatic caller gets the
|
|
503
|
+
* same refusals the tool layer gives instead of a raw Playwright timeout.
|
|
504
|
+
*/
|
|
505
|
+
async waitForCondition(condition, signal) {
|
|
506
|
+
return runWait(this, normalizeWaitCondition(condition, SESSION_WAIT_CAP_MS), signal);
|
|
507
|
+
}
|
|
434
508
|
async tabs() {
|
|
435
509
|
return this.run(async () => {
|
|
436
510
|
this.assertLive();
|
|
@@ -491,6 +565,14 @@ export class PlaywrightSession {
|
|
|
491
565
|
return pageData(this);
|
|
492
566
|
}
|
|
493
567
|
async evaluate(expression, signal) {
|
|
568
|
+
// The switch is enforced HERE, not only in the tool layer: browser_evaluate
|
|
569
|
+
// used to be guarded by tool.ts alone, so anything holding the service (a
|
|
570
|
+
// sibling plugin, a script) could run arbitrary page JavaScript with the
|
|
571
|
+
// user's logins while the setting said off (measured 2026-10-26). An
|
|
572
|
+
// unpublished gate set counts as OFF — a deployment that never said yes has
|
|
573
|
+
// not said yes.
|
|
574
|
+
if (!(getGates()?.allowEvaluate ?? false))
|
|
575
|
+
throw evaluateDisabled();
|
|
494
576
|
return this.run(async () => {
|
|
495
577
|
this.assertLive();
|
|
496
578
|
this.provider.touch(this.owner);
|
|
@@ -511,11 +593,19 @@ export class PlaywrightSession {
|
|
|
511
593
|
* Send one raw CDP command on the current page's session and return its
|
|
512
594
|
* result. This is the "direct protocol" escape hatch behind every other
|
|
513
595
|
* tool — the model can inspect geometry, dispatch trusted input events or
|
|
514
|
-
* query the DOM the same way Chrome DevTools does.
|
|
515
|
-
*
|
|
516
|
-
*
|
|
596
|
+
* query the DOM the same way Chrome DevTools does.
|
|
597
|
+
*
|
|
598
|
+
* Both rules live here as well as in the tool layer, deliberately: the
|
|
599
|
+
* allow-list and the switch must hold for anything that reaches this method,
|
|
600
|
+
* not just for the registered tool. Before 2026-10-26 only `browser_cdp`
|
|
601
|
+
* checked them, so a caller working through the service could send
|
|
602
|
+
* `Runtime.evaluate` — arbitrary JavaScript — with the switch off (measured).
|
|
517
603
|
*/
|
|
518
604
|
async cdp(method, params, signal) {
|
|
605
|
+
if (!(getGates()?.allowCdp ?? false))
|
|
606
|
+
throw cdpDisabled();
|
|
607
|
+
if (!isCdpMethodAllowed(method))
|
|
608
|
+
throw new BrowserError('CDP_DENIED', cdpDenialMessage(method));
|
|
519
609
|
return this.run(async () => {
|
|
520
610
|
this.assertLive();
|
|
521
611
|
this.provider.touch(this.owner);
|
package/lib/settings-card.js
CHANGED
|
@@ -148,7 +148,9 @@ function registerDiskRoutes(ctx) {
|
|
|
148
148
|
sendJson(res, 200, { ...report, browserActive: getEnabled() === true });
|
|
149
149
|
}
|
|
150
150
|
catch (error) {
|
|
151
|
-
sendJson(res, 500, {
|
|
151
|
+
sendJson(res, 500, {
|
|
152
|
+
error: { code: 'DISK_SCAN', message: String(error && error.message ? error.message : error) },
|
|
153
|
+
});
|
|
152
154
|
}
|
|
153
155
|
},
|
|
154
156
|
}));
|
|
@@ -177,15 +179,24 @@ function registerDiskRoutes(ctx) {
|
|
|
177
179
|
const targets = [];
|
|
178
180
|
for (const value of payload.targets) {
|
|
179
181
|
if (!isTargetId(value))
|
|
180
|
-
return sendJson(res, 400, {
|
|
182
|
+
return sendJson(res, 400, {
|
|
183
|
+
error: { code: 'UNKNOWN_TARGET', message: 'unknown target: ' + String(value) },
|
|
184
|
+
});
|
|
181
185
|
targets.push(value);
|
|
182
186
|
}
|
|
183
187
|
try {
|
|
184
|
-
const report = await clean({
|
|
188
|
+
const report = await clean({
|
|
189
|
+
...diskPaths(),
|
|
190
|
+
targets,
|
|
191
|
+
dryRun: payload.dryRun,
|
|
192
|
+
browserActive: getEnabled() === true,
|
|
193
|
+
});
|
|
185
194
|
sendJson(res, 200, report);
|
|
186
195
|
}
|
|
187
196
|
catch (error) {
|
|
188
|
-
sendJson(res, 500, {
|
|
197
|
+
sendJson(res, 500, {
|
|
198
|
+
error: { code: 'DISK_CLEAN', message: String(error && error.message ? error.message : error) },
|
|
199
|
+
});
|
|
189
200
|
}
|
|
190
201
|
},
|
|
191
202
|
}));
|
package/lib/tool.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import z from '@deepseek-ai/schemastery';
|
|
2
2
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
3
3
|
import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
4
|
-
import { BrowserError } from "./errors.js";
|
|
4
|
+
import { BrowserError, cdpDisabled, evaluateDisabled } from "./errors.js";
|
|
5
5
|
import { registeredSuffixes } from "./contract.js";
|
|
6
6
|
import { getEnabled, publishGates, publishLoginSites } from "./runtime-state.js";
|
|
7
7
|
import { summarizeLoginSites } from "./login-sites.js";
|
|
@@ -9,6 +9,7 @@ import { isCdpMethodAllowed, cdpDenialMessage } from "./cdp-policy.js";
|
|
|
9
9
|
import { SCREENSHOT_SCHEMA, SNAPSHOT_SCHEMA, TABS_SCHEMA } from "./tool-schemas.js";
|
|
10
10
|
import { parseJsonText, extractionPrompt } from "./extract.js";
|
|
11
11
|
import { detectChallenge } from "./challenge.js";
|
|
12
|
+
import { describeWait, normalizeWaitCondition } from "./wait-conditions.js";
|
|
12
13
|
import { renderDiagnostics, renderScreenshotValue, renderSnapshotValue, renderTabsValue, snapshotValue, } from "./tool-render.js";
|
|
13
14
|
export { detectChallenge };
|
|
14
15
|
/** Cordis plugin name used by loader diagnostics. */
|
|
@@ -58,12 +59,17 @@ export function apply(ctx, config) {
|
|
|
58
59
|
// Tell the Settings page what is actually in effect. It cannot read this
|
|
59
60
|
// config itself (the value may come from a bundle patch, a profile patch or a
|
|
60
61
|
// home-level patch), so the tool family publishes it at registration and the
|
|
61
|
-
// switch shows the truth instead of a guess.
|
|
62
|
-
//
|
|
62
|
+
// switch shows the truth instead of a guess.
|
|
63
|
+
//
|
|
64
|
+
// These are the CAPABILITY values, not `registered.has(...)`: the session
|
|
65
|
+
// layer enforces the switch too, and `registerDisabledTools` deliberately
|
|
66
|
+
// registers a switched-off tool so the model can discover it. Reading
|
|
67
|
+
// registration as permission published "on" for a gate that was off — the
|
|
68
|
+
// settings card showed a switch that was not there.
|
|
63
69
|
publishGates({
|
|
64
|
-
allowEvaluate:
|
|
65
|
-
allowCdp:
|
|
66
|
-
diagnostics:
|
|
70
|
+
allowEvaluate: config.allowEvaluate,
|
|
71
|
+
allowCdp: config.allowCdp,
|
|
72
|
+
diagnostics: config.diagnostics,
|
|
67
73
|
});
|
|
68
74
|
/** Register one gated tool only when this configuration keeps it live. */
|
|
69
75
|
const registerMaybe = (suffix, definition) => {
|
|
@@ -119,10 +125,9 @@ export function apply(ctx, config) {
|
|
|
119
125
|
};
|
|
120
126
|
ctx.tools.register(defineTool({
|
|
121
127
|
name: p('navigate'),
|
|
122
|
-
description: 'Navigate the persistent browser to a URL and return the new
|
|
123
|
-
'
|
|
124
|
-
'
|
|
125
|
-
'same URL reloads the page and can discard in-progress user input; only navigate when the URL must actually change.',
|
|
128
|
+
description: 'Navigate the persistent browser to a URL and return the new snapshot: visible elements with stable refs for later click/fill. ' +
|
|
129
|
+
'IRON RULE: if the page is already on that URL, do NOT navigate again — a re-navigation reloads it and can ' +
|
|
130
|
+
'discard input the user has typed; only navigate when the URL must actually change.',
|
|
126
131
|
parameters: {
|
|
127
132
|
url: { type: 'string', required: true, description: 'Absolute http(s) URL to open.' },
|
|
128
133
|
waitFor: {
|
|
@@ -140,21 +145,17 @@ export function apply(ctx, config) {
|
|
|
140
145
|
}));
|
|
141
146
|
ctx.tools.register(defineTool({
|
|
142
147
|
name: p('snapshot'),
|
|
143
|
-
description: '
|
|
144
|
-
'
|
|
145
|
-
'
|
|
148
|
+
description: 'Snapshot the current page: URL, title, visible elements with stable ref= ids. ' +
|
|
149
|
+
'Take one after dynamic changes to refresh refs before clicking. ' +
|
|
150
|
+
'A named element with NO [ref=...] has no handle of its own: ' +
|
|
146
151
|
p('click') +
|
|
147
|
-
'
|
|
148
|
-
|
|
149
|
-
'
|
|
150
|
-
'An effect can be real even when the URL does not change and no tab opens (a same-page overlay or popup): compare refs and content between snapshots instead of trusting the URL. ' +
|
|
151
|
-
'A native dialog (alert / confirm / prompt / beforeunload) is not part of the page: it is left PENDING and blocks the page, reported at the top of the result as [dialog] type "message"; answer it with ' +
|
|
152
|
+
' reaches it by exact label text (also with mode:"hover") while it is visible. ' +
|
|
153
|
+
'A real effect can leave the URL and tab count unchanged (same-page overlay): compare refs and content between snapshots, not the URL. ' +
|
|
154
|
+
'A native dialog (alert / confirm / prompt / beforeunload) is not page content: it stays PENDING, blocks the page, and is reported as [dialog] type "message"; answer it with ' +
|
|
152
155
|
p('dialog') +
|
|
153
|
-
'({ accept })
|
|
154
|
-
'IRON RULE:
|
|
155
|
-
'
|
|
156
|
-
'Pick ONE verification channel per step: prefer a snapshot for DOM/locator ground truth or a screenshot ' +
|
|
157
|
-
'when visual confirmation matters, but avoid requesting both by default.',
|
|
156
|
+
'({ accept }) — only when the user authorized exactly that action. ' +
|
|
157
|
+
'IRON RULE: after any click/type/scroll this is the cheapest check that the action took effect (URL, selection, toast, new content). ' +
|
|
158
|
+
'Use one verification channel per step: snapshot for DOM truth, screenshot when visuals matter — not both by default.',
|
|
158
159
|
parameters: {
|
|
159
160
|
interactiveOnly: {
|
|
160
161
|
type: 'boolean',
|
|
@@ -184,19 +185,19 @@ export function apply(ctx, config) {
|
|
|
184
185
|
parameters: {
|
|
185
186
|
ref: {
|
|
186
187
|
type: 'string',
|
|
187
|
-
description: '
|
|
188
|
+
description: 'Ref from the latest snapshot (e.g. e12); preferred when the snapshot has one.',
|
|
188
189
|
},
|
|
189
190
|
text: {
|
|
190
191
|
type: 'string',
|
|
191
|
-
description: 'Visible label of a target shown WITHOUT a ref, copied exactly
|
|
192
|
+
description: 'Visible label of a target shown WITHOUT a ref, copied exactly; the first visible match is used and the note names it.',
|
|
192
193
|
},
|
|
193
194
|
x: {
|
|
194
195
|
type: 'integer',
|
|
195
|
-
description: 'Viewport x in CSS
|
|
196
|
+
description: 'Viewport x in CSS px; with y, a raw click — mode "click" only, last resort.',
|
|
196
197
|
},
|
|
197
198
|
y: {
|
|
198
199
|
type: 'integer',
|
|
199
|
-
description: 'Viewport y in CSS
|
|
200
|
+
description: 'Viewport y in CSS px; with x, a raw click — mode "click" only, last resort.',
|
|
200
201
|
},
|
|
201
202
|
mode: {
|
|
202
203
|
type: 'string',
|
|
@@ -205,7 +206,7 @@ export function apply(ctx, config) {
|
|
|
205
206
|
},
|
|
206
207
|
precise: {
|
|
207
208
|
type: 'boolean',
|
|
208
|
-
description: 'Ref clicks only: click the geometric centre through native input
|
|
209
|
+
description: 'Ref clicks only: click the geometric centre through native input.',
|
|
209
210
|
},
|
|
210
211
|
},
|
|
211
212
|
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
@@ -237,12 +238,12 @@ export function apply(ctx, config) {
|
|
|
237
238
|
}));
|
|
238
239
|
ctx.tools.register(defineTool({
|
|
239
240
|
name: p('dialog'),
|
|
240
|
-
description: 'Answer the
|
|
241
|
-
'
|
|
241
|
+
description: 'Answer the dialog blocking the page (alert / confirm / prompt / beforeunload) and return the fresh snapshot. ' +
|
|
242
|
+
'Never auto-answered: it stays PENDING until this call; the raising call and ' +
|
|
242
243
|
p('snapshot') +
|
|
243
|
-
' report it
|
|
244
|
-
'accept: true confirms
|
|
245
|
-
'A prompt takes
|
|
244
|
+
' report it, every other tool fails with DIALOG_PENDING. ' +
|
|
245
|
+
'accept: true confirms (OK / leave page), false cancels. Only when the user explicitly authorized exactly this action (delete / pay / submit / overwrite) — never on your own initiative; say in your reply which dialog you answered and how. ' +
|
|
246
|
+
'A prompt takes promptText. With no dialog pending this errors NO_DIALOG: a dialog the page never raised cannot be accepted.',
|
|
246
247
|
parameters: {
|
|
247
248
|
accept: {
|
|
248
249
|
type: 'boolean',
|
|
@@ -266,19 +267,16 @@ export function apply(ctx, config) {
|
|
|
266
267
|
ctx.tools.register(defineTool({
|
|
267
268
|
name: p('fill'),
|
|
268
269
|
description: 'Replace the value of the input or select referenced by the latest snapshot. ' +
|
|
269
|
-
'`value` is
|
|
270
|
-
'For <select> elements the text selects the matching option label. ' +
|
|
270
|
+
'`value` is an alias of `text`; for <select> the text selects the matching option label. ' +
|
|
271
271
|
'Credential fields (password, one-time code, card number) are refused with CREDENTIAL_FIELD: ' +
|
|
272
|
-
'a human types secrets in the visible window, so ask them and re-snapshot
|
|
273
|
-
'IRON RULE:
|
|
274
|
-
'
|
|
275
|
-
'
|
|
276
|
-
'inspect the field DOM first — ' +
|
|
272
|
+
'a human types secrets in the visible window, so ask them and re-snapshot. ' +
|
|
273
|
+
'IRON RULE: verify in the returned snapshot that the field holds the value — frameworks can ' +
|
|
274
|
+
'silently reject or transform input (controlled components, contenteditable, input-mask/currency). ' +
|
|
275
|
+
'If it is wrong or missing, do NOT re-fill or append: inspect the field DOM first (' +
|
|
277
276
|
p('evaluate') +
|
|
278
277
|
' reading value/outerHTML, or ' +
|
|
279
278
|
p('cdp') +
|
|
280
|
-
' Input.insertText for contenteditable
|
|
281
|
-
'say so) — understand why it was ignored, then act.',
|
|
279
|
+
' Input.insertText for contenteditable; both are gated, so say so when one is missing), understand why it was ignored, then act.',
|
|
282
280
|
parameters: {
|
|
283
281
|
ref: { type: 'string', required: true, description: 'Input or select ref (e.g. e7) from the latest snapshot.' },
|
|
284
282
|
text: {
|
|
@@ -356,27 +354,54 @@ export function apply(ctx, config) {
|
|
|
356
354
|
}));
|
|
357
355
|
ctx.tools.register(defineTool({
|
|
358
356
|
name: p('wait'),
|
|
359
|
-
description: 'Wait
|
|
357
|
+
description: 'Wait for ONE condition: `selector` (element becomes visible), `text` (visible text appears), ' +
|
|
358
|
+
'`networkIdle` (no request for 500ms) or `ms` (plain sleep). Prefer a goal over `ms` — it returns ' +
|
|
359
|
+
'the moment the page is ready, neither too early nor wasteful. Reports what it waited for and how long.',
|
|
360
360
|
parameters: {
|
|
361
361
|
ms: {
|
|
362
362
|
type: 'integer',
|
|
363
|
-
|
|
364
|
-
|
|
363
|
+
description: 'Fixed sleep in milliseconds, up to ' +
|
|
364
|
+
String(config.maxWaitMs) +
|
|
365
|
+
'. Use it only when nothing observable marks the end of the wait.',
|
|
366
|
+
},
|
|
367
|
+
selector: { type: 'string', description: 'Wait until an element matching this CSS selector is visible.' },
|
|
368
|
+
text: { type: 'string', description: 'Wait until visible text containing this string appears.' },
|
|
369
|
+
networkIdle: { type: 'boolean', description: 'Wait until the page stops issuing requests for 500ms.' },
|
|
370
|
+
timeoutMs: {
|
|
371
|
+
type: 'integer',
|
|
372
|
+
description: 'Upper bound for goal conditions: default 15000, max ' + String(config.maxWaitMs) + '.',
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
output: {
|
|
376
|
+
schema: { type: 'json' },
|
|
377
|
+
render: (_args, value) => {
|
|
378
|
+
const outcome = value;
|
|
379
|
+
return [{ type: 'text', text: 'Waited for ' + outcome.waitedFor + ' (' + String(outcome.elapsedMs) + 'ms).' }];
|
|
365
380
|
},
|
|
366
381
|
},
|
|
367
|
-
output: { schema: { type: 'null' }, render: (_args, _value) => [{ type: 'text', text: 'Waited.' }] },
|
|
368
382
|
async execute(args, exec) {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
383
|
+
const condition = {
|
|
384
|
+
...(args.ms === undefined ? {} : { ms: args.ms }),
|
|
385
|
+
...(args.selector === undefined ? {} : { selector: args.selector }),
|
|
386
|
+
...(args.text === undefined ? {} : { text: args.text }),
|
|
387
|
+
...(args.networkIdle === undefined ? {} : { networkIdle: args.networkIdle }),
|
|
388
|
+
...(args.timeoutMs === undefined ? {} : { timeoutMs: args.timeoutMs }),
|
|
389
|
+
};
|
|
390
|
+
// Validate before acquiring: a malformed call must not launch a browser.
|
|
391
|
+
const normalized = normalizeWaitCondition(condition, config.maxWaitMs);
|
|
372
392
|
const session = await acquire(exec);
|
|
373
|
-
|
|
374
|
-
|
|
393
|
+
if (normalized.kind === 'ms') {
|
|
394
|
+
const started = Date.now();
|
|
395
|
+
await session.wait(normalized.ms, exec.signal);
|
|
396
|
+
return { waitedFor: describeWait(normalized), elapsedMs: Date.now() - started };
|
|
397
|
+
}
|
|
398
|
+
const outcome = await session.waitForCondition(condition, exec.signal);
|
|
399
|
+
return { waitedFor: outcome.waitedFor, elapsedMs: outcome.elapsedMs };
|
|
375
400
|
},
|
|
376
401
|
}));
|
|
377
402
|
ctx.tools.register(defineTool({
|
|
378
403
|
name: p('tabs'),
|
|
379
|
-
description: 'List
|
|
404
|
+
description: 'List open tabs: index, URL, title. [active] marks the tab every other tool acts on.',
|
|
380
405
|
parameters: {},
|
|
381
406
|
output: { schema: TABS_SCHEMA, render: renderTabsValue },
|
|
382
407
|
async execute(_args, exec) {
|
|
@@ -547,7 +572,7 @@ export function apply(ctx, config) {
|
|
|
547
572
|
},
|
|
548
573
|
async execute(args, exec) {
|
|
549
574
|
if (!config.allowEvaluate) {
|
|
550
|
-
throw
|
|
575
|
+
throw evaluateDisabled();
|
|
551
576
|
}
|
|
552
577
|
const session = await acquire(exec);
|
|
553
578
|
return (await session.evaluate(args.expression, exec.signal));
|
|
@@ -583,7 +608,7 @@ export function apply(ctx, config) {
|
|
|
583
608
|
},
|
|
584
609
|
async execute(args, exec) {
|
|
585
610
|
if (!config.allowCdp) {
|
|
586
|
-
throw
|
|
611
|
+
throw cdpDisabled();
|
|
587
612
|
}
|
|
588
613
|
if (!isCdpMethodAllowed(args.method)) {
|
|
589
614
|
throw new BrowserError('CDP_DENIED', cdpDenialMessage(args.method));
|
package/lib/types.d.ts
CHANGED
|
@@ -117,6 +117,29 @@ export interface TabInfo {
|
|
|
117
117
|
/** True for the tab the session currently drives. */
|
|
118
118
|
readonly active: boolean;
|
|
119
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* What `browser_wait` should wait for. Exactly one of `ms`, `selector`, `text`
|
|
122
|
+
* or `networkIdle` per call; `timeoutMs` bounds the three goal conditions.
|
|
123
|
+
*/
|
|
124
|
+
export interface WaitCondition {
|
|
125
|
+
/** Fixed sleep in milliseconds (the pre-0.7.6 behaviour, unchanged). */
|
|
126
|
+
readonly ms?: number;
|
|
127
|
+
/** Wait until an element matching this CSS selector is visible. */
|
|
128
|
+
readonly selector?: string;
|
|
129
|
+
/** Wait until visible text containing this string appears. */
|
|
130
|
+
readonly text?: string;
|
|
131
|
+
/** Wait until the page stops issuing requests for 500ms. */
|
|
132
|
+
readonly networkIdle?: boolean;
|
|
133
|
+
/** Upper bound for a goal condition; defaults to 15000 and is capped by the deployment. */
|
|
134
|
+
readonly timeoutMs?: number;
|
|
135
|
+
}
|
|
136
|
+
/** What a completed wait reports back. */
|
|
137
|
+
export interface WaitOutcome {
|
|
138
|
+
/** Human description of the satisfied condition. */
|
|
139
|
+
readonly waitedFor: string;
|
|
140
|
+
/** Wall-clock milliseconds the wait actually took. */
|
|
141
|
+
readonly elapsedMs: number;
|
|
142
|
+
}
|
|
120
143
|
/** PNG capture of a page or element. */
|
|
121
144
|
export interface ScreenshotCapture {
|
|
122
145
|
readonly mime: 'image/png';
|
|
@@ -200,6 +223,11 @@ export interface BrowserSession {
|
|
|
200
223
|
forward(signal?: AbortSignal): Promise<BrowserSnapshot>;
|
|
201
224
|
/** Wait a bounded duration, letting lazy content arrive. */
|
|
202
225
|
wait(ms: number, signal?: AbortSignal): Promise<void>;
|
|
226
|
+
/**
|
|
227
|
+
* Wait for a goal instead of a duration: an element, a piece of text, or a
|
|
228
|
+
* quiet network. `wait(ms)` stays as it was, so this is purely additive.
|
|
229
|
+
*/
|
|
230
|
+
waitForCondition(condition: WaitCondition, signal?: AbortSignal): Promise<WaitOutcome>;
|
|
203
231
|
/** List open tabs. */
|
|
204
232
|
tabs(): Promise<readonly TabInfo[]>;
|
|
205
233
|
/** Activate the tab at the given index. */
|
package/lib/url-policy.d.ts
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this host inside the allow-list? Empty list means "any host".
|
|
3
|
+
*
|
|
4
|
+
* Suffix matching is deliberately anchored one level down: `example.com`
|
|
5
|
+
* admits `example.com` and `a.example.com`, never `notexample.com` and never
|
|
6
|
+
* `example.com.evil.net`. A configured entry is compared after
|
|
7
|
+
* normalisation (lower case, leading dot dropped), because a user writing
|
|
8
|
+
* `.example.com` means the domain, not a subdomain literally named "example".
|
|
9
|
+
* @param host - hostname to test, e.g. "a.example.com".
|
|
10
|
+
* @param allowedDomains - configured suffixes; empty means everything passes.
|
|
11
|
+
* @returns true when the host is allowed.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isHostAllowed(host: string, allowedDomains: readonly string[]): boolean;
|
|
1
14
|
/** Validate one absolute URL against the navigation policy. */
|
|
2
15
|
export declare function assertAllowedUrl(raw: string, allowedDomains: readonly string[]): URL;
|
package/lib/url-policy.js
CHANGED
|
@@ -3,6 +3,29 @@
|
|
|
3
3
|
* @module dsh-browser-playwright-codex/url-policy
|
|
4
4
|
*/
|
|
5
5
|
import { BrowserError } from "./errors.js";
|
|
6
|
+
/**
|
|
7
|
+
* Is this host inside the allow-list? Empty list means "any host".
|
|
8
|
+
*
|
|
9
|
+
* Suffix matching is deliberately anchored one level down: `example.com`
|
|
10
|
+
* admits `example.com` and `a.example.com`, never `notexample.com` and never
|
|
11
|
+
* `example.com.evil.net`. A configured entry is compared after
|
|
12
|
+
* normalisation (lower case, leading dot dropped), because a user writing
|
|
13
|
+
* `.example.com` means the domain, not a subdomain literally named "example".
|
|
14
|
+
* @param host - hostname to test, e.g. "a.example.com".
|
|
15
|
+
* @param allowedDomains - configured suffixes; empty means everything passes.
|
|
16
|
+
* @returns true when the host is allowed.
|
|
17
|
+
*/
|
|
18
|
+
export function isHostAllowed(host, allowedDomains) {
|
|
19
|
+
if (allowedDomains.length === 0)
|
|
20
|
+
return true;
|
|
21
|
+
const target = host.toLowerCase();
|
|
22
|
+
return allowedDomains.some((raw) => {
|
|
23
|
+
const suffix = raw.trim().toLowerCase().replace(/^\.+/, '');
|
|
24
|
+
if (suffix === '')
|
|
25
|
+
return false;
|
|
26
|
+
return target === suffix || target.endsWith('.' + suffix);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
6
29
|
/** Validate one absolute URL against the navigation policy. */
|
|
7
30
|
export function assertAllowedUrl(raw, allowedDomains) {
|
|
8
31
|
let parsed;
|
|
@@ -17,8 +40,7 @@ export function assertAllowedUrl(raw, allowedDomains) {
|
|
|
17
40
|
}
|
|
18
41
|
if (allowedDomains.length > 0) {
|
|
19
42
|
const host = parsed.hostname.toLowerCase();
|
|
20
|
-
|
|
21
|
-
if (!allowed) {
|
|
43
|
+
if (!isHostAllowed(host, allowedDomains)) {
|
|
22
44
|
throw new BrowserError('URL_NOT_ALLOWED', 'host ' + parsed.hostname + ' is not in allowedDomains');
|
|
23
45
|
}
|
|
24
46
|
}
|