dsh-browser-playwright-codex 0.7.4 → 0.7.5
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 +33 -0
- package/README.md +5 -5
- 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/index.d.ts +4 -2
- package/lib/index.js +3 -2
- 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/provider.d.ts +42 -0
- package/lib/provider.js +80 -1
- package/lib/session.d.ts +33 -3
- package/lib/session.js +86 -5
- package/lib/settings-card.js +15 -4
- package/lib/tool.js +13 -8
- package/lib/url-policy.d.ts +13 -0
- package/lib/url-policy.js +24 -2
- package/package.json +1 -1
- package/scripts/dsh-browser-verify.mjs +34 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
## 0.7.5 — 2026-10-26
|
|
2
|
+
|
|
3
|
+
一句话摘要:**两条安全洞补上,同时唤醒不再多开浏览器** —— 白名单从"门口打一次勾"变成"装在浏览器的请求上",`allowEvaluate` / `allowCdp` 从工具层下沉到会话层(绕过工具直接拿服务也拒),两个任务同时要浏览器时共用一次启动,默认持久化模式下「最多几个会话」也真的算数了。
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **白名单可被绕过(安全)**:入口检查只覆盖 `navigate` / 带 `href` 的点击 / 切标签 / 开标签,页面自己 `location.href='外站'`、meta refresh、`<form action="外站">` 提交、网页自开的弹窗全都绕得过去。实测把名单设成 `127.0.0.1` 后,页面脚本一跳就落在了 `localhost` 上。现在新增 `src/navigation-guard.ts`,把名单**装在这个浏览器 context 的请求上**:发往名单外主机的**导航**当场掐断并报 `URL_NOT_ALLOWED`,**数据请求**(`fetch` / `xhr` / `EventSource`)同样掐断;`script` / 图片 / CSS / 字体 / 媒体这类渲染资源放行,否则页面只会看起来是坏的。空名单(默认)完全不接管路由,所以默认行为一个字没变。
|
|
8
|
+
- **302 跳转是请求拦截看不见的那一跳**:Chromium 在它自己的网络栈里跟随重定向,`context.route()` 根本不会为那一跳触发(实测)。所以每个动作收尾时还会核对页面**最终落在哪个主机**——落在名单外就报 `URL_NOT_ALLOWED`,并把标签页重置成 `about:blank`,绝不留一个可以继续驱动的越界页面。
|
|
9
|
+
- **evaluate / CDP 的开关只在工具层挡(安全)**:此前只有 `tool.ts` 检查 `allowEvaluate` / `allowCdp`,任何拿到浏览器服务的代码(兄弟插件、脚本)都能直接调 `session.evaluate` 跑任意页面 JS,或用 `session.cdp` 发白名单外的 `Runtime.evaluate`(两条都实测跑通)。现在 `session.evaluate` / `session.cdp` 自己就拒(`EVALUATE_DISABLED` / `CDP_DISABLED`),CDP 还要再过一道白名单(`CDP_DENIED`)。**没发布过的门禁算关闭**——部署没说「可以」就是「不可以」。
|
|
10
|
+
- **两个任务同时唤醒会多开浏览器**:`ensureContext` / `ensureBrowser` 没有在途合并,两个会话同时醒来都看到"还没有 context",于是各自 `launchPersistentContext` 同一个 profile。实测后果比"多开"更难看:第二个死在 Chrome 的单例锁上,恢复流程**把正在运行的那个浏览器的锁文件删了**,而调用方收到的错误是「你没装浏览器」——锁被删正是之后关不干净、残留进程的来路。现在并发调用共享同一次启动,并且在途启动结束就清空;恢复路径也不再动活浏览器的锁文件。
|
|
11
|
+
- **`maxSessions` 在默认(持久化)模式不生效**:这个上限只写在非持久化分支里,而默认就是持久化模式,所以形同虚设。现在它按**会话句柄**生效:最久没用、且当前没有操作在跑的句柄会被回收,共用窗口一动不动,那个 owner 下次拿到一个新句柄(同一批标签页,索引回到 0)。顺便修掉 `browser-lifecycle.ts` 一处复制粘贴多出来的重复赋值。
|
|
12
|
+
- **设置卡可能把关闭的门禁显示成开着**:`publishGates` 原来上报的是「工具是否注册」,而 `registerDisabledTools: true` 会故意把关着的工具也注册(好让模型能发现它并读到说明),于是卡片把关闭的能力显示为开启。现在上报的是能力值本身。
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`src/navigation-guard.ts`**:名单的请求层实现,含"文档请求 / 数据请求 / 渲染资源"三分类与拒绝记录。
|
|
17
|
+
- **公开导出(只增不减)**:`isHostAllowed`、`installHostGuard`、`BlockedRequest`、`evaluateDisabled`、`cdpDisabled`。
|
|
18
|
+
- **`tests/host-guard.test.ts`(4 条,真浏览器)**:空名单不插手(默认行为不变)、302 越界被拒并报出主机名、页面自己跳转永远到不了名单外、`fetch` 到名单外**服务器一个请求都没收到**。
|
|
19
|
+
- **`tests/gates-enforcement.test.ts`(5 条,不需要浏览器)**:没发布门禁时 `evaluate` / `cdp` 都拒、开了 CDP 门禁 `Runtime.evaluate` 仍被白名单拒、门禁开关对会话层依旧权威、`getGates()` 报的是能力值。
|
|
20
|
+
- **`tests/concurrency.test.ts`(3 条,真浏览器)**:三个会话同时唤醒共用一个窗口、两个会话同时唤醒共用一个浏览器、持久化模式下 `maxSessions` 按 LRU 回收句柄且窗口不动。
|
|
21
|
+
- **随包验收新增 `[0]` 段(3 条)**:不经工具层直接调 `session.evaluate` / `session.cdp` 必须被拒;开了门禁后 `Runtime.evaluate` 仍报 `CDP_DENIED`。
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **行为变化**:代码层调用 `evaluate` / `cdp` 现在也要过门禁,且未发布门禁 = 关闭。走工具层的老路径行为不变(工具层先给出同样口径的错误)。
|
|
26
|
+
- **`allowedDomains` 的语义收紧**:填了名单,就不再只是"插件不去",而是"这个浏览器不去"。
|
|
27
|
+
- 三个测试文件(`provider` / `regressions` / `scenarios`,另加 `live-smoke`)显式发布门禁——它们本来就直接调 `session.evaluate`,等于把自己声明成"开了开关的部署"。
|
|
28
|
+
|
|
29
|
+
### Tests
|
|
30
|
+
|
|
31
|
+
- 全套 243 → **255 条**(+12):通过 252、跳过 3、失败 0。
|
|
32
|
+
- `npm run verify` 57 → **60 项**:`[0]` 段三条是新增的门禁回归闸。
|
|
33
|
+
|
|
1
34
|
## 0.7.4 — 2026-10-23
|
|
2
35
|
|
|
3
36
|
一句话摘要:**设置卡可以切换有头 / 无头** —— 运行时开关,不用改 YAML、不用重启 DSH;切换在**下一次操作**时生效(先导出登录态,再按新模式重开窗口)。默认仍是有头。
|
package/README.md
CHANGED
|
@@ -232,7 +232,7 @@ All tunables are patchable through the profile's `cordis.patch.yml` (later layer
|
|
|
232
232
|
ignoreHTTPSErrors: false
|
|
233
233
|
allowedDomains: [] # e.g. ['github.com'] restricts navigation
|
|
234
234
|
idleTimeoutMs: 0 # shipped value: never auto-close (schema default 600000 = 10 min)
|
|
235
|
-
maxSessions: 8 #
|
|
235
|
+
maxSessions: 8 # live-session cap; persistent mode caps handles, still one window
|
|
236
236
|
snapshot:
|
|
237
237
|
engine: legacy # 'legacy' (default) or 'aria' (Playwright ariaSnapshot, refs aligned back)
|
|
238
238
|
maxNodes: 500 # token budget per snapshot tree
|
|
@@ -330,7 +330,7 @@ npm install
|
|
|
330
330
|
npm test # full suite: engine/action integration on real Chrome, policy, rendering,
|
|
331
331
|
# schema assembly, plus the realistic scenario suites below
|
|
332
332
|
npm run verify # naming/folding + click/hover/dialog acceptance; self-contained, prints its own count
|
|
333
|
-
# (
|
|
333
|
+
# (60 in a checkout; from an npm install it prints 59 + "1 skipped", because the
|
|
334
334
|
# `lib/`-vs-`src/` check has no `src/` to compare against)
|
|
335
335
|
npm run verify:live # same, plus a live sample of a real page (needs network)
|
|
336
336
|
npm run doctor # does the installed harness still export everything this plugin imports?
|
|
@@ -426,7 +426,7 @@ links below go to GitHub rather than to files inside the package:
|
|
|
426
426
|
- **Headless servers must opt in** — the bundle defaults to `headless: false`; on a headless/remote host set `launch.headless: true`.
|
|
427
427
|
- **Selector-free, not vision-free** — interaction is a11y-tree based; purely visual widgets (canvas, WebGL, custom-drawn controls) may expose no refs. `browser_screenshot` + an image-capable model is the fallback.
|
|
428
428
|
- **Cross-origin iframes** appear as leaf `(frame)` nodes without refs; same-origin frames are walked up to two levels deep.
|
|
429
|
-
- **Evaluate gate is config, not approval** — enabling `allowEvaluate` trusts the model with arbitrary page JavaScript; compose it with the harness approval/permission policy for stricter control.
|
|
429
|
+
- **Evaluate gate is config, not approval** — enabling `allowEvaluate` trusts the model with arbitrary page JavaScript; compose it with the harness approval/permission policy for stricter control. The gate is enforced in **two** places: `tool.ts` refuses before a browser is even launched, and the session itself refuses too (`EVALUATE_DISABLED` / `CDP_DISABLED`), so a sibling plugin or a script holding the browser service cannot go around it. A gate that was never published counts as **off**. `browser_cdp` additionally passes the CDP allow-list at the session layer, so `Runtime.evaluate` is rejected even with the switch on.
|
|
430
430
|
- **The package ships both power gates OFF, and that is deliberate** — `cordis.patch.yml` is part of the package, so anything it enables is enabled for *everyone who installs this*. It therefore sets `allowEvaluate: false` and `allowCdp: false` (the code defaults), and each deployment opts in through its **own profile patch** (`~/.dsh/profiles/<name>/cordis.patch.yml`). A package that shipped them on would be handing every installer a backdoor they never asked for. Snippet:
|
|
431
431
|
```yaml
|
|
432
432
|
# dsh replaces an entry's config object wholesale — list every key you rely on.
|
|
@@ -444,7 +444,7 @@ links below go to GitHub rather than to files inside the package:
|
|
|
444
444
|
- **`launch.viewport` only applies headless** — the visible window is a real window: it tracks the desktop size (`browser-lifecycle.ts` passes `viewport: null` in headful), so setting 1280×800 there changes nothing. Pin the size by running `headless: true`, or resize the window itself.
|
|
445
445
|
- **Chromium family only** — Firefox/WebKit channels are not probed; providers are swappable if another engine is needed.
|
|
446
446
|
- **Extract needs a dedicated model route** — it does not reuse the main request's route; misconfiguration fails loudly at call time.
|
|
447
|
-
-
|
|
447
|
+
- **`allowedDomains` is enforced on the wire, not just at the entry points** — the host list is installed on the browser context itself (`navigation-guard.ts`), so a navigation nobody vetted is refused by the browser: `location.href = …`, `meta refresh`, a `<form>` posting to another host and a page-opened popup all stop at the request. Data requests (`fetch` / `xhr` / `EventSource`) to a host outside the list are refused too, so page script cannot move data off-site; rendering assets (script / image / CSS / font / media) are left alone, because blocking them only makes a page look broken. `browser_navigate`, `browser_open_tab`, every navigating click and `browser_switch_tab` still check first — that is what produces the friendly error before anything happens. One case needs a second look, because Chromium follows a redirect inside its own network stack where request interception never sees the hop: after every action the page's final URL is checked, and a landing outside the list is refused with `URL_NOT_ALLOWED` and the tab is reset to `about:blank`. Entry-point checks remain as the second layer for a tab that already existed on a disallowed host (a profile from an older version): the agent simply refuses to drive it. For hard isolation from the whole host stack, still deploy an external network guard.
|
|
448
448
|
- **Stability waits are bounded by design** — a perpetually animating element falls back to a forced click on its current rect rather than stalling; a page that never settles is snapshotted after the cap. This is intentional: the agent must never hang.
|
|
449
449
|
- **Challenge detection reads the responses it has actually seen** — the response-header tier applies to a document response this session observed after it attached to the tab. A tab that was already sitting on a challenge page when the session took it over falls through to the widget and wording tiers and, failing those, to the no-progress backstop: it reports a *possible* wall instead of guessing the vendor. A page that is not recognised still never goes silent: a non-2xx document carries an `[http NNN]` note, and a page with no clickable elements and almost no text carries a `[nothing here]` note.
|
|
450
450
|
|
|
@@ -454,7 +454,7 @@ The scenario suites originally surfaced four hazards, now fixed and regression-t
|
|
|
454
454
|
|
|
455
455
|
- **Stale refs fail fast** — refs carry a per-snapshot nonce, so after a client-side re-render a stale ref matches nothing and `browser_click` fails with `REF_NOT_FOUND` instead of silently acting on a different element.
|
|
456
456
|
- **Link clicks respect `allowedDomains`** — every click path is checked before any input is delivered: `browser_click` — ref mode (the element's `href`), `precise` ref mode, and raw-coordinate mode (the enclosing link found at the point). A disallowed host is rejected with `URL_NOT_ALLOWED`. `javascript:` links are exempt because they run in-page and navigate nowhere; other non-http(s) schemes stay refused.
|
|
457
|
-
- **A page-opened tab outside `allowedDomains` cannot be driven** —
|
|
457
|
+
- **A page-opened tab outside `allowedDomains` cannot be driven** — two layers: the request-level guard refuses the popup's document (so it never loads the disallowed host), and `browser_switch_tab` still refuses to enter a tab that already sits on one (the current tab is kept).
|
|
458
458
|
- **Idle disposal defers during operations** — a navigation slower than `idleTimeoutMs` completes normally; disposal only fires when the session is truly idle.
|
|
459
459
|
- **Mid-navigation snapshots settle** — a `browser_snapshot` racing a JS navigation waits for the new document and retries instead of surfacing a raw "Execution context was destroyed" error.
|
|
460
460
|
|
package/SECURITY.md
CHANGED
|
@@ -114,7 +114,7 @@ Two honest notes:
|
|
|
114
114
|
npm test # 219 tests: contracts, tool schemas, scenarios,
|
|
115
115
|
# aria engine, CDP policy, diff, click/tab allow-lists, snapshot rendering,
|
|
116
116
|
# prompt budget, docs-vs-reality consistency
|
|
117
|
-
npm run verify #
|
|
117
|
+
npm run verify # 60 self-contained acceptance checks in a checkout (59 checks + 1 skipped
|
|
118
118
|
# from an npm install, where there is no src/): no DSH, no network, local fixture server
|
|
119
119
|
npm run doctor # the host packages this plugin imports are still exporting what it uses
|
|
120
120
|
npm run cost # resident prompt cost of the registered tool surface
|
|
@@ -4,9 +4,21 @@ import type { PlaywrightProvider } from './provider.ts';
|
|
|
4
4
|
* profile-backed window is launched once and reused until closed by the
|
|
5
5
|
* last owner, idle disposal, or an external close; login state lives in
|
|
6
6
|
* the profile plus the exported state file, so reopening is seamless.
|
|
7
|
+
*
|
|
8
|
+
* Concurrent callers share ONE launch. Without this, two sessions waking up at
|
|
9
|
+
* the same moment both saw "no context" and both called launchPersistentContext
|
|
10
|
+
* on the same profile directory: the loser died on Chrome's profile singleton,
|
|
11
|
+
* the recovery path deleted the running browser's lock files, and the caller was
|
|
12
|
+
* told "install a Chromium-family browser" (measured 2026-10-26).
|
|
7
13
|
*/
|
|
8
14
|
export declare function ensureContext(provider: PlaywrightProvider): Promise<import("playwright-core").BrowserContext>;
|
|
9
15
|
/** Remove stale Chrome singleton lock files inside the profile directory. */
|
|
10
16
|
export declare function clearChromeLocks(provider: PlaywrightProvider): Promise<void>;
|
|
11
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Launch the browser once, probing the configured or auto-detected channel.
|
|
19
|
+
*
|
|
20
|
+
* Concurrent callers share one launch, for the same reason ensureContext does:
|
|
21
|
+
* otherwise two sessions wake up, both see `browser === undefined`, and two
|
|
22
|
+
* browsers are launched (the reported "两个任务同时唤醒会多开浏览器").
|
|
23
|
+
*/
|
|
12
24
|
export declare function ensureBrowser(provider: PlaywrightProvider): Promise<import("playwright-core").Browser>;
|
package/lib/browser-lifecycle.js
CHANGED
|
@@ -14,8 +14,26 @@ import { BrowserError, launchFailed } from "./errors.js";
|
|
|
14
14
|
* profile-backed window is launched once and reused until closed by the
|
|
15
15
|
* last owner, idle disposal, or an external close; login state lives in
|
|
16
16
|
* the profile plus the exported state file, so reopening is seamless.
|
|
17
|
+
*
|
|
18
|
+
* Concurrent callers share ONE launch. Without this, two sessions waking up at
|
|
19
|
+
* the same moment both saw "no context" and both called launchPersistentContext
|
|
20
|
+
* on the same profile directory: the loser died on Chrome's profile singleton,
|
|
21
|
+
* the recovery path deleted the running browser's lock files, and the caller was
|
|
22
|
+
* told "install a Chromium-family browser" (measured 2026-10-26).
|
|
17
23
|
*/
|
|
18
24
|
export async function ensureContext(provider) {
|
|
25
|
+
const inFlight = provider.contextLaunch;
|
|
26
|
+
if (inFlight !== undefined)
|
|
27
|
+
return await inFlight;
|
|
28
|
+
const launch = launchContext(provider).finally(() => {
|
|
29
|
+
if (provider.contextLaunch === launch)
|
|
30
|
+
provider.contextLaunch = undefined;
|
|
31
|
+
});
|
|
32
|
+
provider.contextLaunch = launch;
|
|
33
|
+
return await launch;
|
|
34
|
+
}
|
|
35
|
+
/** The actual launch, shared by every concurrent {@link ensureContext} caller. */
|
|
36
|
+
async function launchContext(provider) {
|
|
19
37
|
if (provider.context !== undefined) {
|
|
20
38
|
try {
|
|
21
39
|
const browser = provider.context.browser();
|
|
@@ -60,14 +78,19 @@ export async function ensureContext(provider) {
|
|
|
60
78
|
provider.launchedHeadless = headless;
|
|
61
79
|
}
|
|
62
80
|
catch (error) {
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
81
|
+
// Another launch may have won while this one was starting (an explicit
|
|
82
|
+
// `dispose()` racing a fresh acquire, a second provider on the same
|
|
83
|
+
// profile). Hand back that window: clearing the lock files it is running
|
|
84
|
+
// on would let a later launch start a SECOND browser on this profile.
|
|
85
|
+
if (provider.context !== undefined)
|
|
86
|
+
return provider.context;
|
|
87
|
+
// A stale Chrome singleton lock from a dirty shutdown breaks a relaunch
|
|
88
|
+
// into the same profile; clear the lock files and retry once before
|
|
89
|
+
// surfacing the failure.
|
|
66
90
|
if (error instanceof Error && /user data directory|SingletonLock|ProcessSingleton/i.test(error.message)) {
|
|
67
91
|
await provider.clearChromeLocks();
|
|
68
92
|
provider.context = await chromium.launchPersistentContext(provider.profileDir, { ...baseOptions, ...extra });
|
|
69
93
|
provider.launchedHeadless = headless;
|
|
70
|
-
provider.launchedHeadless = headless;
|
|
71
94
|
}
|
|
72
95
|
else {
|
|
73
96
|
throw error;
|
|
@@ -95,6 +118,9 @@ export async function ensureContext(provider) {
|
|
|
95
118
|
catch {
|
|
96
119
|
/* malformed state file must not block startup */
|
|
97
120
|
}
|
|
121
|
+
// Allow-list on the wire: registered before the first navigation, so a
|
|
122
|
+
// page cannot leave the list by itself (navigation-guard.ts).
|
|
123
|
+
await provider.installHostGuard(ctx);
|
|
98
124
|
await ctx.addInitScript(SNAPSHOT_SCRIPT);
|
|
99
125
|
// The initial blank page predates addInitScript: reload so the
|
|
100
126
|
// snapshot engine and restored localStorage apply on first use.
|
|
@@ -147,8 +173,26 @@ export async function clearChromeLocks(provider) {
|
|
|
147
173
|
}
|
|
148
174
|
}
|
|
149
175
|
}
|
|
150
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Launch the browser once, probing the configured or auto-detected channel.
|
|
178
|
+
*
|
|
179
|
+
* Concurrent callers share one launch, for the same reason ensureContext does:
|
|
180
|
+
* otherwise two sessions wake up, both see `browser === undefined`, and two
|
|
181
|
+
* browsers are launched (the reported "两个任务同时唤醒会多开浏览器").
|
|
182
|
+
*/
|
|
151
183
|
export async function ensureBrowser(provider) {
|
|
184
|
+
const inFlight = provider.browserLaunch;
|
|
185
|
+
if (inFlight !== undefined)
|
|
186
|
+
return await inFlight;
|
|
187
|
+
const launch = launchBrowser(provider).finally(() => {
|
|
188
|
+
if (provider.browserLaunch === launch)
|
|
189
|
+
provider.browserLaunch = undefined;
|
|
190
|
+
});
|
|
191
|
+
provider.browserLaunch = launch;
|
|
192
|
+
return await launch;
|
|
193
|
+
}
|
|
194
|
+
/** The actual launch, shared by every concurrent {@link ensureBrowser} caller. */
|
|
195
|
+
async function launchBrowser(provider) {
|
|
152
196
|
if (provider.browser !== undefined)
|
|
153
197
|
return provider.browser;
|
|
154
198
|
const { executablePath, channel } = provider.config.launch;
|
package/lib/errors.d.ts
CHANGED
|
@@ -23,3 +23,15 @@ export declare class BrowserError extends Error {
|
|
|
23
23
|
}
|
|
24
24
|
/** Wrap an unknown cause into a launch failure with the code preserved. */
|
|
25
25
|
export declare function launchFailed(cause: unknown): BrowserError;
|
|
26
|
+
/**
|
|
27
|
+
* "browser_evaluate is switched off". One wording, thrown by both layers that
|
|
28
|
+
* enforce it: the tool layer (before a browser is even launched) and the
|
|
29
|
+
* session itself (so a programmatic caller cannot go around the switch).
|
|
30
|
+
* @returns the denial, ready to throw.
|
|
31
|
+
*/
|
|
32
|
+
export declare function evaluateDisabled(): BrowserError;
|
|
33
|
+
/**
|
|
34
|
+
* "browser_cdp is switched off". Same two-layer rule as {@link evaluateDisabled}.
|
|
35
|
+
* @returns the denial, ready to throw.
|
|
36
|
+
*/
|
|
37
|
+
export declare function cdpDisabled(): BrowserError;
|
package/lib/errors.js
CHANGED
|
@@ -19,3 +19,19 @@ export function launchFailed(cause) {
|
|
|
19
19
|
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
20
20
|
return new BrowserError('BROWSER_LAUNCH_FAILED', 'failed to launch the browser: ' + detail);
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* "browser_evaluate is switched off". One wording, thrown by both layers that
|
|
24
|
+
* enforce it: the tool layer (before a browser is even launched) and the
|
|
25
|
+
* session itself (so a programmatic caller cannot go around the switch).
|
|
26
|
+
* @returns the denial, ready to throw.
|
|
27
|
+
*/
|
|
28
|
+
export function evaluateDisabled() {
|
|
29
|
+
return new BrowserError('EVALUATE_DISABLED', 'browser_evaluate is disabled; set allowEvaluate: true on the browser-tool plugin to enable arbitrary page JavaScript');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* "browser_cdp is switched off". Same two-layer rule as {@link evaluateDisabled}.
|
|
33
|
+
* @returns the denial, ready to throw.
|
|
34
|
+
*/
|
|
35
|
+
export function cdpDisabled() {
|
|
36
|
+
return new BrowserError('CDP_DISABLED', 'browser_cdp is disabled; set allowCdp: true on the browser-tool plugin to enable raw CDP inspection commands');
|
|
37
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* session-scoped browser contexts, screenshots as attachments.
|
|
5
5
|
* @module dsh-browser-playwright-codex
|
|
6
6
|
*/
|
|
7
|
-
export { BrowserError, launchFailed } from './errors.ts';
|
|
7
|
+
export { BrowserError, cdpDisabled, evaluateDisabled, launchFailed } from './errors.ts';
|
|
8
8
|
export type { BrowserErrorCode } from './errors.ts';
|
|
9
9
|
export { ALWAYS_ON_SUFFIXES, ERROR_CODES, GATED_SUFFIXES, TOOL_SUFFIXES, registeredSuffixes, registeredToolNames, toolNames, } from './contract.ts';
|
|
10
10
|
export type { ToolSuffix, ToolSurfaceConfig } from './contract.ts';
|
|
@@ -15,7 +15,9 @@ export type { SnapshotOptions, PageDataOptions } from './injected.ts';
|
|
|
15
15
|
export { default as BrowserRuntime } from './service.ts';
|
|
16
16
|
export type { BrowserRuntimeConfig } from './service.ts';
|
|
17
17
|
export { PlaywrightProvider } from './provider.ts';
|
|
18
|
-
export { assertAllowedUrl } from './url-policy.ts';
|
|
18
|
+
export { assertAllowedUrl, isHostAllowed } from './url-policy.ts';
|
|
19
|
+
export { installHostGuard } from './navigation-guard.ts';
|
|
20
|
+
export type { BlockedRequest } from './navigation-guard.ts';
|
|
19
21
|
export { Config as PlaywrightConfigSchema } from './config.ts';
|
|
20
22
|
export type { PlaywrightConfig, PageData } from './config.ts';
|
|
21
23
|
export type { BrowserNode, BrowserProvider, BrowserSession, BrowserSnapshot, LoadState, ScreenshotCapture, TabInfo, } from './types.ts';
|
package/lib/index.js
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
* session-scoped browser contexts, screenshots as attachments.
|
|
5
5
|
* @module dsh-browser-playwright-codex
|
|
6
6
|
*/
|
|
7
|
-
export { BrowserError, launchFailed } from "./errors.js";
|
|
7
|
+
export { BrowserError, cdpDisabled, evaluateDisabled, launchFailed } from "./errors.js";
|
|
8
8
|
export { ALWAYS_ON_SUFFIXES, ERROR_CODES, GATED_SUFFIXES, TOOL_SUFFIXES, registeredSuffixes, registeredToolNames, toolNames, } from "./contract.js";
|
|
9
9
|
export { CallId, toolCallId } from "./compat.js";
|
|
10
10
|
export { SNAPSHOT_SCRIPT } from "./injected.js";
|
|
11
11
|
export { default as BrowserRuntime } from "./service.js";
|
|
12
12
|
export { PlaywrightProvider } from "./provider.js";
|
|
13
|
-
export { assertAllowedUrl } from "./url-policy.js";
|
|
13
|
+
export { assertAllowedUrl, isHostAllowed } from "./url-policy.js";
|
|
14
|
+
export { installHostGuard } from "./navigation-guard.js";
|
|
14
15
|
export { Config as PlaywrightConfigSchema } from "./config.js";
|
|
15
16
|
// Single loader row (2026-10-19): the package root is the plugin. The three
|
|
16
17
|
// sub-entries below stay importable, but the loader mounts this one.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 白名单的网络层守卫:把「只能访问这些站点」变成**路上的一道闸**,而不是只在调用入口
|
|
3
|
+
* 打一次勾。
|
|
4
|
+
*
|
|
5
|
+
* 起因(2026-10-26 实测):入口检查只覆盖 navigate / 点击带 href 的链接 / 切标签 / 开标签。
|
|
6
|
+
* 页面自己 `location.href = 'http://别的站点'`、meta refresh、302 跳转、表单提交,全都
|
|
7
|
+
* 绕过白名单——实测把名单设成 `127.0.0.1` 后,页面脚本一跳就落在了 `localhost` 上。
|
|
8
|
+
* 入口检查在这里永远追不上:那些导航不是插件发起的。
|
|
9
|
+
*
|
|
10
|
+
* 做法:接管这个 context 的每一条请求(context.route),按主机名判定。
|
|
11
|
+
* - **文档请求**(导航、iframe):不在名单里 → 掐断,并记下来。调用方在动作收尾时
|
|
12
|
+
* 用它把「我点了但页面没动」变成一条明确的 URL_NOT_ALLOWED,而不是一次静默。
|
|
13
|
+
* - **数据请求**(xhr / fetch / eventsource):不在名单里 → 掐断。页面脚本不该拿着
|
|
14
|
+
* 这个浏览器的身份去白名单外的站点取数据。
|
|
15
|
+
* - 其余子资源(script / img / css / font / media):放行。它们几乎是页面渲染的刚需
|
|
16
|
+
* (CDN、头像、字体),且不带目标站点的凭据;拦掉只会让页面看起来是坏的。
|
|
17
|
+
*
|
|
18
|
+
* 只在配置了名单时才接管:空名单的语义是「任意站点」,此时接管路由纯属拖慢每一页
|
|
19
|
+
* (Playwright 的路由会关掉 HTTP 缓存)。
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-browser-playwright-codex/navigation-guard
|
|
22
|
+
*/
|
|
23
|
+
import type { BrowserContext } from 'playwright-core';
|
|
24
|
+
/** One request the guard refused, in the shape callers report it. */
|
|
25
|
+
export interface BlockedRequest {
|
|
26
|
+
/** Full requested URL. */
|
|
27
|
+
url: string;
|
|
28
|
+
/** Hostname that was not in the allow-list. */
|
|
29
|
+
host: string;
|
|
30
|
+
/** Playwright resourceType: document, xhr, script, … */
|
|
31
|
+
resourceType: string;
|
|
32
|
+
/** True for a navigation-level block (a document request). */
|
|
33
|
+
document: boolean;
|
|
34
|
+
/** Epoch milliseconds. */
|
|
35
|
+
at: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Refuse every request to a host outside the allow-list for one context.
|
|
39
|
+
*
|
|
40
|
+
* Installed once per context, before the first navigation. When the configured
|
|
41
|
+
* list is empty nothing is installed: "any host" must not pay the routing cost.
|
|
42
|
+
* @param context - the context to guard.
|
|
43
|
+
* @param allowedDomains - reads the live allow-list.
|
|
44
|
+
* @param onBlocked - called for each refused request; the caller keeps the last
|
|
45
|
+
* document-level block and turns it into an error for the action that caused it.
|
|
46
|
+
* @returns true when a guard was installed, false when the list is empty.
|
|
47
|
+
*/
|
|
48
|
+
export declare function installHostGuard(context: BrowserContext, allowedDomains: () => readonly string[], onBlocked: (blocked: BlockedRequest) => void): Promise<boolean>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isHostAllowed } from "./url-policy.js";
|
|
2
|
+
/** Resource types that carry data off the page and are refused outside the allow-list. */
|
|
3
|
+
const DATA_RESOURCE_TYPES = new Set(['xhr', 'fetch', 'eventsource']);
|
|
4
|
+
/**
|
|
5
|
+
* Refuse every request to a host outside the allow-list for one context.
|
|
6
|
+
*
|
|
7
|
+
* Installed once per context, before the first navigation. When the configured
|
|
8
|
+
* list is empty nothing is installed: "any host" must not pay the routing cost.
|
|
9
|
+
* @param context - the context to guard.
|
|
10
|
+
* @param allowedDomains - reads the live allow-list.
|
|
11
|
+
* @param onBlocked - called for each refused request; the caller keeps the last
|
|
12
|
+
* document-level block and turns it into an error for the action that caused it.
|
|
13
|
+
* @returns true when a guard was installed, false when the list is empty.
|
|
14
|
+
*/
|
|
15
|
+
export async function installHostGuard(context, allowedDomains, onBlocked) {
|
|
16
|
+
const domains = allowedDomains();
|
|
17
|
+
if (domains.length === 0)
|
|
18
|
+
return false;
|
|
19
|
+
await context.route('**/*', async (route) => {
|
|
20
|
+
const request = route.request();
|
|
21
|
+
let host;
|
|
22
|
+
try {
|
|
23
|
+
host = new URL(request.url()).hostname;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Not a URL we can judge (data:, blob:, about:, chrome-extension: …).
|
|
27
|
+
// Neither a host nor a site the policy talks about: let the browser decide.
|
|
28
|
+
await route.continue().catch(() => { });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (isHostAllowed(host, allowedDomains())) {
|
|
32
|
+
await route.continue().catch(() => { });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const resourceType = request.resourceType();
|
|
36
|
+
const document = resourceType === 'document';
|
|
37
|
+
// Sub-resources the page needs in order to render are left alone; only the
|
|
38
|
+
// request classes that move the user or move data are refused.
|
|
39
|
+
if (!document && !DATA_RESOURCE_TYPES.has(resourceType)) {
|
|
40
|
+
await route.continue().catch(() => { });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (document)
|
|
44
|
+
onBlocked({ url: request.url(), host, resourceType, document, at: Date.now() });
|
|
45
|
+
await route.abort('blockedbyclient').catch(() => { });
|
|
46
|
+
});
|
|
47
|
+
return true;
|
|
48
|
+
}
|
package/lib/page-actions.js
CHANGED
|
@@ -121,6 +121,9 @@ export async function navigate(session, url, waitUntil, signal) {
|
|
|
121
121
|
catch (error) {
|
|
122
122
|
if (isAbortError(error) || signal?.aborted === true)
|
|
123
123
|
throw error;
|
|
124
|
+
// A redirect chain can leave the allow-list: the guard refuses the hop and
|
|
125
|
+
// Playwright reports a plain navigation failure. Name the real cause.
|
|
126
|
+
session.throwIfNavigationBlocked();
|
|
124
127
|
throw asTimeoutError('navigation', error);
|
|
125
128
|
}
|
|
126
129
|
return session.settleAndSnapshot(page, true);
|
|
@@ -716,6 +719,7 @@ export async function back(session, signal) {
|
|
|
716
719
|
catch (error) {
|
|
717
720
|
if (isAbortError(error))
|
|
718
721
|
throw error;
|
|
722
|
+
session.throwIfNavigationBlocked();
|
|
719
723
|
throw asTimeoutError('navigation', error);
|
|
720
724
|
}
|
|
721
725
|
return session.settleAndSnapshot(page, true);
|
|
@@ -730,6 +734,7 @@ export async function forward(session, signal) {
|
|
|
730
734
|
catch (error) {
|
|
731
735
|
if (isAbortError(error))
|
|
732
736
|
throw error;
|
|
737
|
+
session.throwIfNavigationBlocked();
|
|
733
738
|
throw asTimeoutError('navigation', error);
|
|
734
739
|
}
|
|
735
740
|
return session.settleAndSnapshot(page, true);
|
|
@@ -776,6 +781,7 @@ export async function openTab(session, url, waitUntil, signal) {
|
|
|
776
781
|
catch (error) {
|
|
777
782
|
if (isAbortError(error))
|
|
778
783
|
throw error;
|
|
784
|
+
session.throwIfNavigationBlocked();
|
|
779
785
|
throw asTimeoutError('navigation', error);
|
|
780
786
|
}
|
|
781
787
|
return session.settleAndSnapshot(page, true);
|
package/lib/page-snapshot.js
CHANGED
|
@@ -77,6 +77,11 @@ export async function settleAndSnapshot(session, page, navChanged) {
|
|
|
77
77
|
});
|
|
78
78
|
if (raced.blocked)
|
|
79
79
|
return session.blockedSnapshot(page, raced.pending);
|
|
80
|
+
// A page can leave the allow-list without any entry-point check seeing it: the
|
|
81
|
+
// request-level guard catches self-started navigations, and this catches the
|
|
82
|
+
// redirect the browser followed on its own.
|
|
83
|
+
session.throwIfNavigationBlocked();
|
|
84
|
+
await session.assertCurrentUrlAllowed(page);
|
|
80
85
|
return raced.value;
|
|
81
86
|
}
|
|
82
87
|
/**
|
package/lib/playwright.d.ts
CHANGED
|
@@ -11,6 +11,6 @@ export declare const inject: string[];
|
|
|
11
11
|
*/
|
|
12
12
|
export { Config, type PageData, type PlaywrightConfig } from './config.ts';
|
|
13
13
|
export { PlaywrightProvider } from './provider.ts';
|
|
14
|
-
export { assertAllowedUrl } from './url-policy.ts';
|
|
14
|
+
export { assertAllowedUrl, isHostAllowed } from './url-policy.ts';
|
|
15
15
|
export { resolveSnapshotEngine } from './page-snapshot.ts';
|
|
16
16
|
export declare function apply(ctx: Context, config: PlaywrightConfig): void;
|
package/lib/playwright.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* page-snapshot.ts capture, ref signatures, ref lookup
|
|
12
12
|
* page-diagnostics.ts console messages, page errors, network records
|
|
13
13
|
* browser-lifecycle.ts launching/reusing the persistent context, window state
|
|
14
|
+
* window-focus.ts is the window minimized / create a tab without raising it
|
|
15
|
+
* navigation-guard.ts the allow-list on the wire (refuses off-list requests)
|
|
14
16
|
* login-state.ts exporting and restoring login state
|
|
15
17
|
* @module dsh-browser-playwright-codex/playwright
|
|
16
18
|
*/
|
|
@@ -33,7 +35,7 @@ export const inject = ['browser'];
|
|
|
33
35
|
// subpath stopped re-exporting it). The other names keep the pre-split public surface intact.
|
|
34
36
|
export { Config } from "./config.js";
|
|
35
37
|
export { PlaywrightProvider } from "./provider.js";
|
|
36
|
-
export { assertAllowedUrl } from "./url-policy.js";
|
|
38
|
+
export { assertAllowedUrl, isHostAllowed } from "./url-policy.js";
|
|
37
39
|
export { resolveSnapshotEngine } from "./page-snapshot.js";
|
|
38
40
|
export function apply(ctx, config) {
|
|
39
41
|
const provider = new PlaywrightProvider(config);
|
package/lib/provider.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Browser, type BrowserContext, type Dialog, type Page } from 'playwright-core';
|
|
2
2
|
import { PlaywrightSession } from './session.ts';
|
|
3
3
|
import type { PlaywrightConfig } from './config.ts';
|
|
4
|
+
import { type BlockedRequest } from './navigation-guard.ts';
|
|
4
5
|
/**
|
|
5
6
|
* Playwright-backed {@link BrowserProvider}: one shared browser, one context
|
|
6
7
|
* per owner key, LRU eviction, idle disposal, ref-based snapshot interaction.
|
|
@@ -50,6 +51,26 @@ export declare class PlaywrightProvider {
|
|
|
50
51
|
/** Owner that raised the dialog currently parked on a page. */
|
|
51
52
|
lastActor: string | undefined;
|
|
52
53
|
lastStatePersist: number;
|
|
54
|
+
/**
|
|
55
|
+
* Launch in flight, so two sessions waking up at the same time share ONE
|
|
56
|
+
* launch instead of racing for the same profile directory.
|
|
57
|
+
*
|
|
58
|
+
* Measured 2026-10-26: two concurrent acquires both saw "no context yet" and
|
|
59
|
+
* both called launchPersistentContext. The second died on Chrome's profile
|
|
60
|
+
* singleton, the recovery path deleted the RUNNING browser's lock files, and
|
|
61
|
+
* the caller got "install a Chromium-family browser". The lock files being
|
|
62
|
+
* gone is what let a later launch start a second browser on the same profile
|
|
63
|
+
* and leave processes behind.
|
|
64
|
+
*/
|
|
65
|
+
contextLaunch: Promise<BrowserContext> | undefined;
|
|
66
|
+
/** Same for the legacy shared-browser launch. */
|
|
67
|
+
browserLaunch: Promise<Browser> | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* The last navigation the host guard refused. Read (and cleared) once by the
|
|
70
|
+
* action that caused it, so "I clicked and nothing happened" becomes a named
|
|
71
|
+
* URL_NOT_ALLOWED instead of a silent no-op.
|
|
72
|
+
*/
|
|
73
|
+
blockedNavigation: BlockedRequest | undefined;
|
|
53
74
|
constructor(config: PlaywrightConfig);
|
|
54
75
|
available(): boolean;
|
|
55
76
|
/** Pages already carrying the dialog guard (WeakSet: pages stay collectable). */
|
|
@@ -98,6 +119,14 @@ export declare class PlaywrightProvider {
|
|
|
98
119
|
*/
|
|
99
120
|
private relaunchIfModeChanged;
|
|
100
121
|
acquire(owner: string): Promise<PlaywrightSession>;
|
|
122
|
+
/**
|
|
123
|
+
* Keep the number of tracked owner handles inside maxSessions in persistent
|
|
124
|
+
* mode. Handles share one window, so dropping the least recently used idle
|
|
125
|
+
* handle costs nothing the user can see; the owner gets a fresh handle on the
|
|
126
|
+
* same window next time. An owner with an operation in flight is never dropped.
|
|
127
|
+
* @param keep - the owner being acquired right now.
|
|
128
|
+
*/
|
|
129
|
+
private evictIdleHandles;
|
|
101
130
|
disposeOwner(owner: string): Promise<void>;
|
|
102
131
|
dispose(): Promise<void>;
|
|
103
132
|
/** Touch the entry's last-used clock and re-arm its idle timer. */
|
|
@@ -120,6 +149,19 @@ export declare class PlaywrightProvider {
|
|
|
120
149
|
ensureContext(): Promise<BrowserContext>;
|
|
121
150
|
/** Remove stale Chrome singleton lock files inside the profile directory. */
|
|
122
151
|
clearChromeLocks(): Promise<void>;
|
|
152
|
+
/**
|
|
153
|
+
* Put the allow-list on the wire for one context: every navigation and every
|
|
154
|
+
* data request outside the list is refused from then on, whoever started it.
|
|
155
|
+
* See navigation-guard.ts for why the entry-point checks alone were not enough.
|
|
156
|
+
* @param context - freshly created context, before its first navigation.
|
|
157
|
+
*/
|
|
158
|
+
installHostGuard(context: BrowserContext): Promise<void>;
|
|
159
|
+
/**
|
|
160
|
+
* Take the pending "a navigation was refused" record, if any. Consumed once:
|
|
161
|
+
* the next action must not inherit the previous action's refusal.
|
|
162
|
+
* @returns the refused navigation, or undefined.
|
|
163
|
+
*/
|
|
164
|
+
takeBlockedNavigation(): BlockedRequest | undefined;
|
|
123
165
|
/**
|
|
124
166
|
* Collect the login-state fallback WITHOUT ever opening a page while the
|
|
125
167
|
* window is put away.
|
package/lib/provider.js
CHANGED
|
@@ -10,10 +10,11 @@ import {} from 'playwright-core';
|
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { PlaywrightSession } from "./session.js";
|
|
13
|
-
import { getEffectiveHeadless, getEnabled, publishConfiguredEngine, publishLoginSites
|
|
13
|
+
import { getEffectiveHeadless, getEnabled, publishConfiguredEngine, publishLoginSites } from "./runtime-state.js";
|
|
14
14
|
import { BrowserError } from "./errors.js";
|
|
15
15
|
import { SNAPSHOT_SCRIPT } from "./injected.js";
|
|
16
16
|
import { isWindowMinimized } from "./window-focus.js";
|
|
17
|
+
import { installHostGuard } from "./navigation-guard.js";
|
|
17
18
|
export class PlaywrightProvider {
|
|
18
19
|
config;
|
|
19
20
|
id = 'playwright';
|
|
@@ -37,6 +38,26 @@ export class PlaywrightProvider {
|
|
|
37
38
|
/** Owner that raised the dialog currently parked on a page. */
|
|
38
39
|
lastActor;
|
|
39
40
|
lastStatePersist = 0;
|
|
41
|
+
/**
|
|
42
|
+
* Launch in flight, so two sessions waking up at the same time share ONE
|
|
43
|
+
* launch instead of racing for the same profile directory.
|
|
44
|
+
*
|
|
45
|
+
* Measured 2026-10-26: two concurrent acquires both saw "no context yet" and
|
|
46
|
+
* both called launchPersistentContext. The second died on Chrome's profile
|
|
47
|
+
* singleton, the recovery path deleted the RUNNING browser's lock files, and
|
|
48
|
+
* the caller got "install a Chromium-family browser". The lock files being
|
|
49
|
+
* gone is what let a later launch start a second browser on the same profile
|
|
50
|
+
* and leave processes behind.
|
|
51
|
+
*/
|
|
52
|
+
contextLaunch = undefined;
|
|
53
|
+
/** Same for the legacy shared-browser launch. */
|
|
54
|
+
browserLaunch = undefined;
|
|
55
|
+
/**
|
|
56
|
+
* The last navigation the host guard refused. Read (and cleared) once by the
|
|
57
|
+
* action that caused it, so "I clicked and nothing happened" becomes a named
|
|
58
|
+
* URL_NOT_ALLOWED instead of a silent no-op.
|
|
59
|
+
*/
|
|
60
|
+
blockedNavigation = undefined;
|
|
40
61
|
constructor(config) {
|
|
41
62
|
this.config = config;
|
|
42
63
|
// Tell the settings card which page-reading mode this deployment was
|
|
@@ -215,6 +236,11 @@ export class PlaywrightProvider {
|
|
|
215
236
|
// window (Codex-style). Entries are lightweight session handles on
|
|
216
237
|
// the shared context; the last owner to leave closes the window.
|
|
217
238
|
const context = await this.ensureContext();
|
|
239
|
+
// maxSessions applies here too, but as a cap on HANDLES: all of them
|
|
240
|
+
// drive the same window, so evicting one costs nothing visible and the
|
|
241
|
+
// owner simply gets a fresh handle (same page set, index back to 0) next
|
|
242
|
+
// time. Before this, the setting was inert in the default mode.
|
|
243
|
+
this.evictIdleHandles(owner);
|
|
218
244
|
const session = new PlaywrightSession(this, owner, context);
|
|
219
245
|
const entry = { context, session, lastUsed: Date.now(), pending: 0, timer: undefined };
|
|
220
246
|
this.entries.set(owner, entry);
|
|
@@ -238,6 +264,8 @@ export class PlaywrightProvider {
|
|
|
238
264
|
ignoreHTTPSErrors: this.config.launch.ignoreHTTPSErrors,
|
|
239
265
|
});
|
|
240
266
|
context.on('page', (page) => this.armDialogGuard(page));
|
|
267
|
+
// Allow-list on the wire, not just at the entry points (navigation-guard.ts).
|
|
268
|
+
await this.installHostGuard(context);
|
|
241
269
|
await context.addInitScript(SNAPSHOT_SCRIPT);
|
|
242
270
|
const session = new PlaywrightSession(this, owner, context);
|
|
243
271
|
const entry = { context, session, lastUsed: Date.now(), pending: 0, timer: undefined };
|
|
@@ -252,6 +280,30 @@ export class PlaywrightProvider {
|
|
|
252
280
|
});
|
|
253
281
|
return session;
|
|
254
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Keep the number of tracked owner handles inside maxSessions in persistent
|
|
285
|
+
* mode. Handles share one window, so dropping the least recently used idle
|
|
286
|
+
* handle costs nothing the user can see; the owner gets a fresh handle on the
|
|
287
|
+
* same window next time. An owner with an operation in flight is never dropped.
|
|
288
|
+
* @param keep - the owner being acquired right now.
|
|
289
|
+
*/
|
|
290
|
+
evictIdleHandles(keep) {
|
|
291
|
+
const limit = this.config.maxSessions;
|
|
292
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
293
|
+
return;
|
|
294
|
+
while (this.entries.size >= limit) {
|
|
295
|
+
const candidates = [...this.entries.entries()]
|
|
296
|
+
.filter(([key, entry]) => key !== keep && entry.pending === 0)
|
|
297
|
+
.sort((left, right) => left[1].lastUsed - right[1].lastUsed);
|
|
298
|
+
const oldest = candidates[0];
|
|
299
|
+
if (oldest === undefined)
|
|
300
|
+
return;
|
|
301
|
+
const [key, entry] = oldest;
|
|
302
|
+
if (entry.timer !== undefined)
|
|
303
|
+
clearTimeout(entry.timer);
|
|
304
|
+
this.entries.delete(key);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
255
307
|
async disposeOwner(owner) {
|
|
256
308
|
const entry = this.entries.get(owner);
|
|
257
309
|
if (entry === undefined)
|
|
@@ -275,6 +327,10 @@ export class PlaywrightProvider {
|
|
|
275
327
|
}
|
|
276
328
|
async dispose() {
|
|
277
329
|
this.launchedHeadless = undefined;
|
|
330
|
+
// A launch still in flight must not re-create a context after disposal.
|
|
331
|
+
this.contextLaunch = undefined;
|
|
332
|
+
this.browserLaunch = undefined;
|
|
333
|
+
this.blockedNavigation = undefined;
|
|
278
334
|
const owners = [...this.entries.keys()];
|
|
279
335
|
for (const owner of owners)
|
|
280
336
|
await this.disposeOwner(owner);
|
|
@@ -364,6 +420,29 @@ export class PlaywrightProvider {
|
|
|
364
420
|
async clearChromeLocks() {
|
|
365
421
|
return clearChromeLocks(this);
|
|
366
422
|
}
|
|
423
|
+
/**
|
|
424
|
+
* Put the allow-list on the wire for one context: every navigation and every
|
|
425
|
+
* data request outside the list is refused from then on, whoever started it.
|
|
426
|
+
* See navigation-guard.ts for why the entry-point checks alone were not enough.
|
|
427
|
+
* @param context - freshly created context, before its first navigation.
|
|
428
|
+
*/
|
|
429
|
+
async installHostGuard(context) {
|
|
430
|
+
this.blockedNavigation = undefined;
|
|
431
|
+
await installHostGuard(context, () => this.config.allowedDomains ?? [], (blocked) => {
|
|
432
|
+
if (blocked.document)
|
|
433
|
+
this.blockedNavigation = blocked;
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Take the pending "a navigation was refused" record, if any. Consumed once:
|
|
438
|
+
* the next action must not inherit the previous action's refusal.
|
|
439
|
+
* @returns the refused navigation, or undefined.
|
|
440
|
+
*/
|
|
441
|
+
takeBlockedNavigation() {
|
|
442
|
+
const blocked = this.blockedNavigation;
|
|
443
|
+
this.blockedNavigation = undefined;
|
|
444
|
+
return blocked;
|
|
445
|
+
}
|
|
367
446
|
/**
|
|
368
447
|
* Collect the login-state fallback WITHOUT ever opening a page while the
|
|
369
448
|
* window is put away.
|
package/lib/session.d.ts
CHANGED
|
@@ -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.
|
|
@@ -278,9 +304,13 @@ export declare class PlaywrightSession {
|
|
|
278
304
|
* Send one raw CDP command on the current page's session and return its
|
|
279
305
|
* result. This is the "direct protocol" escape hatch behind every other
|
|
280
306
|
* tool — the model can inspect geometry, dispatch trusted input events or
|
|
281
|
-
* query the DOM the same way Chrome DevTools does.
|
|
282
|
-
*
|
|
283
|
-
*
|
|
307
|
+
* query the DOM the same way Chrome DevTools does.
|
|
308
|
+
*
|
|
309
|
+
* Both rules live here as well as in the tool layer, deliberately: the
|
|
310
|
+
* allow-list and the switch must hold for anything that reaches this method,
|
|
311
|
+
* not just for the registered tool. Before 2026-10-26 only `browser_cdp`
|
|
312
|
+
* checked them, so a caller working through the service could send
|
|
313
|
+
* `Runtime.evaluate` — arbitrary JavaScript — with the switch off (measured).
|
|
284
314
|
*/
|
|
285
315
|
cdp(method: string, params: Record<string, unknown>, signal?: AbortSignal): Promise<unknown>;
|
|
286
316
|
close(): Promise<void>;
|
package/lib/session.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
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";
|
|
9
11
|
/** Live session over one browser context owned by one caller. */
|
|
10
12
|
export class PlaywrightSession {
|
|
11
13
|
provider;
|
|
@@ -55,6 +57,66 @@ export class PlaywrightSession {
|
|
|
55
57
|
this.owner = owner;
|
|
56
58
|
this.context = context;
|
|
57
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Turn a navigation the host guard refused into a named refusal.
|
|
62
|
+
*
|
|
63
|
+
* A page can navigate itself (location.href, meta refresh, a form submit, a
|
|
64
|
+
* 302) without passing any entry-point check. The guard stops the document
|
|
65
|
+
* request; this reports it, so "I clicked and nothing happened" becomes
|
|
66
|
+
* URL_NOT_ALLOWED naming the host. Consumed once: a later action must not
|
|
67
|
+
* inherit an earlier refusal.
|
|
68
|
+
*/
|
|
69
|
+
throwIfNavigationBlocked() {
|
|
70
|
+
const refused = this.provider.takeBlockedNavigation();
|
|
71
|
+
if (refused === undefined)
|
|
72
|
+
return;
|
|
73
|
+
throw new BrowserError('URL_NOT_ALLOWED', 'the page tried to navigate to ' +
|
|
74
|
+
refused.url +
|
|
75
|
+
' and was blocked: host ' +
|
|
76
|
+
refused.host +
|
|
77
|
+
' is not in allowedDomains. Nothing left this site — the request was stopped before it reached the network. ' +
|
|
78
|
+
'The page you were on is unchanged; tell the user the site tried to leave the allow-list.');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Refuse to go on driving a page that is outside the allow-list.
|
|
82
|
+
*
|
|
83
|
+
* The request-level guard (navigation-guard.ts) catches navigations started
|
|
84
|
+
* from inside the page, but NOT a redirect the browser follows on its own:
|
|
85
|
+
* measured 2026-10-26, `context.route()` never sees the hop after a
|
|
86
|
+
* `continue()`d request, so `127.0.0.1/x` answering `302 → localhost/y` lands
|
|
87
|
+
* on the disallowed host with no route event at all. So every action that
|
|
88
|
+
* settles a page also checks where it actually ended up.
|
|
89
|
+
*
|
|
90
|
+
* The off-list page is abandoned for about:blank (hostless, always allowed)
|
|
91
|
+
* instead of being left there: otherwise the session would wedge on a page it
|
|
92
|
+
* may never drive, and "navigate somewhere allowed" could not fix it.
|
|
93
|
+
* @param page - the page this action ran on.
|
|
94
|
+
*/
|
|
95
|
+
async assertCurrentUrlAllowed(page) {
|
|
96
|
+
const raw = page.url();
|
|
97
|
+
if (raw === '')
|
|
98
|
+
return;
|
|
99
|
+
let target;
|
|
100
|
+
try {
|
|
101
|
+
target = new URL(raw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:')
|
|
107
|
+
return;
|
|
108
|
+
const allowedDomains = this.provider.config.allowedDomains ?? [];
|
|
109
|
+
if (isHostAllowed(target.hostname, allowedDomains))
|
|
110
|
+
return;
|
|
111
|
+
await page.goto('about:blank').catch(() => { });
|
|
112
|
+
throw new BrowserError('URL_NOT_ALLOWED', 'the page ended up on host ' +
|
|
113
|
+
target.hostname +
|
|
114
|
+
' (' +
|
|
115
|
+
raw +
|
|
116
|
+
'), which is not in allowedDomains — a redirect the browser followed on its own. ' +
|
|
117
|
+
'The tab has been reset to about:blank, so nothing outside the allow-list stays reachable. ' +
|
|
118
|
+
'Tell the user: the site tried to send this session off the allow-list.');
|
|
119
|
+
}
|
|
58
120
|
/**
|
|
59
121
|
* The session's cookies, reduced to what the login inference needs. `value` is dropped here,
|
|
60
122
|
* at the only place cookies enter the summary path, so nothing downstream can leak it.
|
|
@@ -120,6 +182,9 @@ export class PlaywrightSession {
|
|
|
120
182
|
}
|
|
121
183
|
/** Run one operation under busy tracking: idle disposal defers until it settles. */
|
|
122
184
|
run(operation) {
|
|
185
|
+
// Every action starts from a clean slate: a refusal recorded during an
|
|
186
|
+
// earlier action must not be reported as this one's outcome.
|
|
187
|
+
this.provider.blockedNavigation = undefined;
|
|
123
188
|
this.provider.beginOp(this.owner);
|
|
124
189
|
return Promise.resolve()
|
|
125
190
|
.then(operation)
|
|
@@ -491,6 +556,14 @@ export class PlaywrightSession {
|
|
|
491
556
|
return pageData(this);
|
|
492
557
|
}
|
|
493
558
|
async evaluate(expression, signal) {
|
|
559
|
+
// The switch is enforced HERE, not only in the tool layer: browser_evaluate
|
|
560
|
+
// used to be guarded by tool.ts alone, so anything holding the service (a
|
|
561
|
+
// sibling plugin, a script) could run arbitrary page JavaScript with the
|
|
562
|
+
// user's logins while the setting said off (measured 2026-10-26). An
|
|
563
|
+
// unpublished gate set counts as OFF — a deployment that never said yes has
|
|
564
|
+
// not said yes.
|
|
565
|
+
if (!(getGates()?.allowEvaluate ?? false))
|
|
566
|
+
throw evaluateDisabled();
|
|
494
567
|
return this.run(async () => {
|
|
495
568
|
this.assertLive();
|
|
496
569
|
this.provider.touch(this.owner);
|
|
@@ -511,11 +584,19 @@ export class PlaywrightSession {
|
|
|
511
584
|
* Send one raw CDP command on the current page's session and return its
|
|
512
585
|
* result. This is the "direct protocol" escape hatch behind every other
|
|
513
586
|
* tool — the model can inspect geometry, dispatch trusted input events or
|
|
514
|
-
* query the DOM the same way Chrome DevTools does.
|
|
515
|
-
*
|
|
516
|
-
*
|
|
587
|
+
* query the DOM the same way Chrome DevTools does.
|
|
588
|
+
*
|
|
589
|
+
* Both rules live here as well as in the tool layer, deliberately: the
|
|
590
|
+
* allow-list and the switch must hold for anything that reaches this method,
|
|
591
|
+
* not just for the registered tool. Before 2026-10-26 only `browser_cdp`
|
|
592
|
+
* checked them, so a caller working through the service could send
|
|
593
|
+
* `Runtime.evaluate` — arbitrary JavaScript — with the switch off (measured).
|
|
517
594
|
*/
|
|
518
595
|
async cdp(method, params, signal) {
|
|
596
|
+
if (!(getGates()?.allowCdp ?? false))
|
|
597
|
+
throw cdpDisabled();
|
|
598
|
+
if (!isCdpMethodAllowed(method))
|
|
599
|
+
throw new BrowserError('CDP_DENIED', cdpDenialMessage(method));
|
|
519
600
|
return this.run(async () => {
|
|
520
601
|
this.assertLive();
|
|
521
602
|
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";
|
|
@@ -58,12 +58,17 @@ export function apply(ctx, config) {
|
|
|
58
58
|
// Tell the Settings page what is actually in effect. It cannot read this
|
|
59
59
|
// config itself (the value may come from a bundle patch, a profile patch or a
|
|
60
60
|
// home-level patch), so the tool family publishes it at registration and the
|
|
61
|
-
// switch shows the truth instead of a guess.
|
|
62
|
-
//
|
|
61
|
+
// switch shows the truth instead of a guess.
|
|
62
|
+
//
|
|
63
|
+
// These are the CAPABILITY values, not `registered.has(...)`: the session
|
|
64
|
+
// layer enforces the switch too, and `registerDisabledTools` deliberately
|
|
65
|
+
// registers a switched-off tool so the model can discover it. Reading
|
|
66
|
+
// registration as permission published "on" for a gate that was off — the
|
|
67
|
+
// settings card showed a switch that was not there.
|
|
63
68
|
publishGates({
|
|
64
|
-
allowEvaluate:
|
|
65
|
-
allowCdp:
|
|
66
|
-
diagnostics:
|
|
69
|
+
allowEvaluate: config.allowEvaluate,
|
|
70
|
+
allowCdp: config.allowCdp,
|
|
71
|
+
diagnostics: config.diagnostics,
|
|
67
72
|
});
|
|
68
73
|
/** Register one gated tool only when this configuration keeps it live. */
|
|
69
74
|
const registerMaybe = (suffix, definition) => {
|
|
@@ -547,7 +552,7 @@ export function apply(ctx, config) {
|
|
|
547
552
|
},
|
|
548
553
|
async execute(args, exec) {
|
|
549
554
|
if (!config.allowEvaluate) {
|
|
550
|
-
throw
|
|
555
|
+
throw evaluateDisabled();
|
|
551
556
|
}
|
|
552
557
|
const session = await acquire(exec);
|
|
553
558
|
return (await session.evaluate(args.expression, exec.signal));
|
|
@@ -583,7 +588,7 @@ export function apply(ctx, config) {
|
|
|
583
588
|
},
|
|
584
589
|
async execute(args, exec) {
|
|
585
590
|
if (!config.allowCdp) {
|
|
586
|
-
throw
|
|
591
|
+
throw cdpDisabled();
|
|
587
592
|
}
|
|
588
593
|
if (!isCdpMethodAllowed(args.method)) {
|
|
589
594
|
throw new BrowserError('CDP_DENIED', cdpDenialMessage(args.method));
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-browser-playwright-codex",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"description": "Browser automation for DeepSeek Harness, one plugin: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised; native dialogs are parked instead of confirmed, CAPTCHA pages are flagged with a note that says stop and hand the page back, password / one-time-code / card fields are refused by browser_fill, and the Settings card (enable/disable, page-reading mode, capability gates) now lives inside this package.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,7 +28,7 @@ import path from 'node:path'
|
|
|
28
28
|
import { PlaywrightProvider } from '../lib/provider.js'
|
|
29
29
|
import { Config as ConfigSchema } from '../lib/config.js'
|
|
30
30
|
import { renderSnapshot } from '../lib/snapshot-render.js'
|
|
31
|
-
import { getEffectiveEngine, setSnapshotEngine } from '../lib/runtime-state.js'
|
|
31
|
+
import { getEffectiveEngine, publishGates, setSnapshotEngine } from '../lib/runtime-state.js'
|
|
32
32
|
|
|
33
33
|
// 验收必须只看代码,不看这台机器上用户的选择。
|
|
34
34
|
// 设置页的「页面识别方式」存在 ~/.dsh/dsh-browser-playwright.state.json,运行时状态层
|
|
@@ -128,6 +128,36 @@ const provider = new PlaywrightProvider(
|
|
|
128
128
|
)
|
|
129
129
|
const session = await provider.acquire('verify')
|
|
130
130
|
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// [0] 门禁在代码层也成立(2026-10-26 之前的漏洞:开关只在工具层挡)
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// 本脚本不经过工具层,直接调 session —— 正是当初绕过门禁的那条路。默认(没发布门禁)
|
|
135
|
+
// 必须拒绝;确认之后再把门禁打开,好让后面的用例能读页面状态。
|
|
136
|
+
console.log('[0] 门禁:没开门禁时代码层也拒绝')
|
|
137
|
+
let evaluateGate = ''
|
|
138
|
+
try {
|
|
139
|
+
await session.evaluate('1 + 1')
|
|
140
|
+
} catch (e) {
|
|
141
|
+
evaluateGate = e.code
|
|
142
|
+
}
|
|
143
|
+
check('evaluate 未开门禁时报 EVALUATE_DISABLED', evaluateGate === 'EVALUATE_DISABLED', evaluateGate)
|
|
144
|
+
let cdpGate = ''
|
|
145
|
+
try {
|
|
146
|
+
await session.cdp('DOM.getDocument', { depth: 1 })
|
|
147
|
+
} catch (e) {
|
|
148
|
+
cdpGate = e.code
|
|
149
|
+
}
|
|
150
|
+
check('cdp 未开门禁时报 CDP_DISABLED', cdpGate === 'CDP_DISABLED', cdpGate)
|
|
151
|
+
publishGates({ allowEvaluate: true, allowCdp: true, diagnostics: true })
|
|
152
|
+
let cdpDenied = ''
|
|
153
|
+
try {
|
|
154
|
+
// 门禁打开也仍然要过白名单:Runtime.evaluate 是任意页面 JS,永远不在名单里。
|
|
155
|
+
await session.cdp('Runtime.evaluate', { expression: 'document.cookie', returnByValue: true })
|
|
156
|
+
} catch (e) {
|
|
157
|
+
cdpDenied = e.code
|
|
158
|
+
}
|
|
159
|
+
check('开门禁后 Runtime.evaluate 仍报 CDP_DENIED', cdpDenied === 'CDP_DENIED', cdpDenied)
|
|
160
|
+
|
|
131
161
|
console.log('[1] 快照取名与折叠')
|
|
132
162
|
const s0 = await session.navigate(URL_, 'load')
|
|
133
163
|
const tree = renderSnapshot(s0)
|
|
@@ -366,6 +396,9 @@ const expectedTools = contractMod.registeredToolNames(toolConfig.toolPrefix, {
|
|
|
366
396
|
registerDisabledTools: toolConfig.registerDisabledTools,
|
|
367
397
|
})
|
|
368
398
|
const byName = (n) => reg.find((t) => t.name === n)
|
|
399
|
+
// apply() 会把有效门禁刷成这份配置里的值(这里出厂默认是关),而下面的用例直接调
|
|
400
|
+
// session 而不是走工具层,所以再打开一次。
|
|
401
|
+
publishGates({ allowEvaluate: true, allowCdp: true, diagnostics: true })
|
|
369
402
|
check('存在 browser_dialog', byName('browser_dialog') !== undefined)
|
|
370
403
|
check(
|
|
371
404
|
'browser_dialog 有 accept 参数',
|