dsh-browser-playwright-codex 0.5.2 → 0.5.4

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +17 -7
  3. package/SECURITY.md +15 -10
  4. package/lib/browser-lifecycle.d.ts +12 -0
  5. package/lib/browser-lifecycle.js +186 -0
  6. package/lib/challenge.d.ts +20 -0
  7. package/lib/challenge.js +40 -0
  8. package/lib/config.d.ts +81 -0
  9. package/lib/config.js +61 -0
  10. package/lib/extract.d.ts +13 -0
  11. package/lib/extract.js +34 -0
  12. package/lib/index.d.ts +4 -2
  13. package/lib/index.js +3 -1
  14. package/lib/login-state.d.ts +10 -0
  15. package/lib/login-state.js +83 -0
  16. package/lib/page-actions.d.ts +134 -0
  17. package/lib/page-actions.js +725 -0
  18. package/lib/page-diagnostics.d.ts +65 -0
  19. package/lib/page-diagnostics.js +181 -0
  20. package/lib/page-probes.d.ts +78 -0
  21. package/lib/page-probes.js +281 -0
  22. package/lib/page-snapshot.d.ts +96 -0
  23. package/lib/page-snapshot.js +280 -0
  24. package/lib/playwright.d.ts +5 -644
  25. package/lib/playwright.js +28 -2602
  26. package/lib/provider.d.ts +157 -0
  27. package/lib/provider.js +352 -0
  28. package/lib/session.d.ts +410 -0
  29. package/lib/session.js +633 -0
  30. package/lib/tool-render.d.ts +20 -0
  31. package/lib/tool-render.js +137 -0
  32. package/lib/tool-schemas.d.ts +76 -0
  33. package/lib/tool-schemas.js +46 -0
  34. package/lib/tool-types.d.ts +82 -0
  35. package/lib/tool-types.js +1 -0
  36. package/lib/tool.d.ts +2 -26
  37. package/lib/tool.js +5 -252
  38. package/lib/url-policy.d.ts +2 -0
  39. package/lib/url-policy.js +26 -0
  40. package/lib/window-focus.d.ts +27 -0
  41. package/lib/window-focus.js +54 -0
  42. package/package.json +1 -1
  43. package/scripts/dsh-browser-verify.mjs +2 -1
  44. package/scripts/prompt-cost.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,61 @@
3
3
  Bilingual by intent: the version headings and the summary of each release are given in both
4
4
  languages, the individual entries follow the language the surrounding code and docs use.
5
5
 
6
+ ## 0.5.4
7
+
8
+ 一句话摘要:**把第三方评审(2026-10-11,8.6/10)指出的"对外表述与现实脱节"修干净,并把这些说法变成守卫** —— 运行时行为零变化。但 README / SECURITY / NOTICE 是**随包发布**的,所以必须发一版:不发版,从 npm 装的用户看到的仍是那句"不在 npm 上"。
9
+
10
+ ### Fixed
11
+
12
+ - **README 声称"这个包不在 npm 上"**(与同一文件里的 `# from npm` 自相矛盾):包从 v0.5.1 起已在 npm 上。改为「From npm (published since v0.5.1)」并给出 npm 安装命令,仓库与本地 link 两种方式保留。
13
+ - **NOTICE.md 的 7 个路径全部失效**(写成 `docs/PLUGIN-README.md`、`docs/DIALOG-POLICY.md` …,实际都在仓库根目录):已按根相对路径改正。同一段里指向**上游文档**的 `docs/browser-safety.md` 保持原样 —— 那是给读者的来源索引,不是我们的文件。顺带修掉 `MODE-TRADE-OFFS.md` 里同类一处。
14
+ - **README"安全一览"写死"2 Critical"**,而 dsh.so 当前扫描是 **1 Critical / 3 Warning / 22 Info**(v0.5.3、75 文件):改为不含数字的说法。
15
+ - **SECURITY.md §2 与表格按当前扫描重写**:Critical 就是 `browser_evaluate` 的动态求值(`src/session.ts` → `lib/session.js`,出厂关闭)、3 条 Warning、22 条 Info;v0.4.9 的 2/5/26 作为历史留在引用块里,并写明"以他们的报告为准"。
16
+ - **SECURITY.md 里 `src/playwright.ts (~:2041)` 这类"文件名 + 行号"引用在 0.5.x 拆分后失效**(该文件现在 55 行):改为模块名 —— 行号会漂,模块名不会。
17
+ - **三份发帖草稿里的"2 个 Critical"**:去掉数字,改为"那个 Critical"。
18
+ - **几处把上游文件写得像我们自己的**:`docs/articles/focus-stealing.md`、`patches/README.md`、`codex-rules.md` 里标明那是**上游仓库**的文件。
19
+ - **README 增加 "Compatibility" 段**:写明构建与测试用的 DSH 版本是 `0.2.0-rc.1`、peer 范围故意留 `*`(换新版 harness 仍可安装)、升级后跑 `npm run doctor` 复核本插件导入的具名导出是否还在。
20
+
21
+ ### Added
22
+
23
+ - **`tests/doc-reality.test.ts`(5 条守卫)**,把"文档对世界说的话"变成 CI 会拦的事实:
24
+ 1. 现行文档不得声称"不在 npm 上"(CHANGELOG 是历史,跳过);
25
+ 2. README 必须给出 npm 安装路径(与第 1 条互为孪生);
26
+ 3. 反引号包着的仓库路径必须真的在那个位置(规则:该路径不存在、但同名文件在本仓库里存在才报 —— 不误伤上游引用;`./`、`../` 相对所在文档解析,其余相对仓库根);
27
+ 4. 文档里的「文件:行号」引用不得越界(行号 > 该文件现在的行数即报错 —— 正是 0.5.x 重构制造的那类腐烂);
28
+ 5. 带数字的 `N Critical` 只允许出现在 SECURITY.md(计数会变,其它文档一律用文字指过去)。
29
+ 每条都做了"有牙复检":把对应的旧文案塞回去,守卫立刻报红。
30
+
31
+ ## 0.5.3
32
+
33
+ 一句话摘要:**源码按职责拆开、可维护性大幅提升,并修掉拆分过程中暴露的三处隐患** —— 对用户可见的行为零变化:工具面、配置接口、出厂默认值全部不动。本版同时修掉中文说明里一处会让人误判权限状态的过期说法。
34
+
35
+ ### Changed
36
+
37
+ - 源码按职责拆开:`playwright.ts` **2,727 → 55 行**,只剩注册入口 `apply()` 与一张给来访者的模块地图;对外零变化(loader 三行、`package.json` exports、`lib/` 入口、工具面全都不动)。做法是"纯搬运、一次一个接缝":每步 `npm test` + `npm run verify` + 解析一致性脚本全绿才提交,任何一步不绿就当场回退(过程中真回退过一次:拆 `tool.ts` 时用启发式切错边界,回退后改用 TypeScript 解析器取精确范围)。
38
+ - `provider.ts`(370 行):profile/context 生命周期、会话注册、空闲回收、弹窗登记、标签记账。
39
+ - `session.ts`(668):会话骨架 —— 持有 page/context,处理弹窗、截图、页面数据、evaluate/CDP,并把动作/快照/诊断转发给对应模块。
40
+ - `page-actions.ts`(786):可点击性检查、点击(几何阶梯 + Playwright 兜底)、填充与写后核对、按键、悬停、滚动、前进后退、标签切换。
41
+ - `page-snapshot.ts`(335):等页面稳定、兼容/智能两种采集、ref 签名与差异、ref 解析与存活校验。
42
+ - `page-diagnostics.ts`(198):控制台消息、页面错误、网络请求的有界记录 + 失败原因归类。
43
+ - `page-probes.ts`(288):会被序列化进浏览器的页内函数(铁律:必须自包含)。
44
+ - `window-focus.ts`(56):承载窗口是否最小化、后台建标签 —— 只为了让窗口/标签不打扰用户。
45
+ - `browser-lifecycle.ts`(183):启动/复用持久化 context、清理 Chrome 锁、启动与 profile 参数。
46
+ - `login-state.ts`(90):登录态导出/恢复(窗口最小化读不到状态时只取 cookies)。
47
+ - `config.ts` / `url-policy.ts` / `challenge.ts` / `extract.ts` / `tool-types.ts` / `tool-schemas.ts` / `tool-render.ts`:配置与常量、URL 策略、人机验证识别、提取工具辅助、工具面类型/schema/渲染。
48
+ - 12 个模块的文件说明与内容对齐(此前 6 处内容变了没改、5 处还留着拆分当天的占位文案)。
49
+
50
+ ### Fixed
51
+
52
+ - **入口子路径恢复 `Config` 导出**(`./playwright`):拆分时 schema 被搬进 `config.ts` 而入口忘了转出 —— Cordis 因此不会补默认值,而配置块里没有 `snapshot`,provider 又直接读 `config.snapshot.engine`,**下次重启就起不来**。该回归只存在于 main、从未随版本发布。现已补上入口契约测试:从 `src` 与 `lib` 两条路检查导出、校验 schema 补出默认值、复现"不含 `snapshot` 的 profile 配置"这一故障场景,并断言 `lib/` 与 `src/` 行为一致(防漂移)。
53
+ - **页内探测守卫覆盖不到 `hitTest`**:它原本是 `resolveClickPoint` 里的嵌套命名函数(同样会被 `keepNames` 改写成 `__name`),现已搬进 `page-probes.ts`;守卫从"手写 4 个函数"改成**枚举该模块所有函数导出**(并断言至少 5 个,避免空跑)。
54
+ - **中文说明写明两个闸门出厂关闭**:原文写"本包自带的 bundle 层是… `allowEvaluate`/`allowCdp` 打开",与出厂 `false`/`false` 矛盾,读者会以为"页面任意 JS / 原始 CDP"默认可用。现在写明出厂关闭并指向 §4.6。
55
+ - **文档守卫补洞**:原规则要求行内出现 `: true|false`,而措辞检查写在这个提前返回**之后** → "…`allowCdp` 打开"这类写法永远走不进检查。现改为两条带邻近性的规则;负向自检三种坏写法全部被抓住,且 `SECURITY.md` 里那句合法表述不被误报。
56
+ - **新增两条守卫**:模块文件说明不得留下拆分脚本的占位文案;页内函数必须自包含(第 5 个函数 `hitTest` 因此归位)。
57
+ - **测试的导航预算 2500ms → 15s**:冷启动的 windows runner 连加载本机 fixture 页都超时(`page.goto: Timeout 2500ms exceeded`),一次挂掉 4 条与超时无关的用例;没有任何测试断言导航超时行为,journeys 另有 25s 兜底。
58
+ - **拆分过程中暴露的 3 处隐患**:`WeakMap<Request>` 实际落在浏览器内置的 DOM `Request`(两个类型身份)、`provider.ts` ⇄ `session.ts` 的**运行时**循环依赖(多个测试文件直接加载失败,用例从 119 掉到 46)、测试与脚本仍在旧路径引用搬走的符号。
59
+ - 清掉搬迁后残留的未使用 import/参数(以 `tsc --noUnusedLocals --noUnusedParameters` 为裁判,28 处 → 0)。
60
+
6
61
  ## 0.5.2
7
62
 
8
63
  一句话摘要:**查出 Windows CI 抖动的真正根因并修掉**—— 测试用的转译器 tsx/esbuild 会开启 `keepNames`,把"要送进浏览器执行"的页内函数里的**命名内部函数**改写成 `__name(fn, "名字")`,而 `__name` 只存在于模块作用域、页面里没有 → 探测每次抛 `ReferenceError`,且被 `catch` 吞成无声的 `unreachable`。后果是**几何点击路径从来没工作过**:所有点击都退化成 Playwright 兜底点击(上限 3 秒),慢速 Windows runner 一超时,就把几何路径那句**误导性**的错误抛了出来。
package/README.md CHANGED
@@ -31,16 +31,22 @@ dsh plugin --profile <name> add github:miqian-nomad/dsh-browser-playwright-codex
31
31
  - **强力工具默认关闭** — `browser_evaluate` / `browser_cdp` are **not registered at all** unless your
32
32
  deployment opts in; this package ships both off.
33
33
 
34
- **安全一览 / Security at a glance** — dsh.so's scanner reports *2 Critical* on this repository, and
35
- both findings **are** the implementation of `browser_evaluate`, which this package ships switched off.
36
- Every finding is explained gate by gate in [SECURITY.md](SECURITY.md), together with what the plugin
37
- can and cannot do with your logins (URL policy, CDP allow-list, dialog policy, no telemetry, and it
38
- never types passwords or 2FA codes).
34
+ **安全一览 / Security at a glance** — dsh.so's scanner flags this repository's **one Critical finding**, and that finding **is** the
35
+ implementation of `browser_evaluate` (its dynamic evaluation), which this package ships switched off.
36
+ [SECURITY.md](SECURITY.md) maps every finding to the gate that contains it, and explains what the plugin
37
+ can and cannot do with your logins (URL policy, CDP allow-list, dialog policy, no telemetry, and it never
38
+ types passwords or 2FA codes). Counts move as the scanner and the code change — their own report is the
39
+ live number.
39
40
 
40
41
  ## Install
41
42
 
42
- This package is **not on npm** — the repository is the distribution channel, so install it from GitHub
43
- (or link a checkout):
43
+ From npm (published since v0.5.1 — [npmjs.com/package/dsh-browser-playwright-codex](https://www.npmjs.com/package/dsh-browser-playwright-codex)):
44
+
45
+ ```sh
46
+ dsh plugin --profile <name> add dsh-browser-playwright-codex
47
+ ```
48
+
49
+ Or straight from the repository / a checkout:
44
50
 
45
51
  ```sh
46
52
  dsh plugin --profile <name> add github:miqian-nomad/dsh-browser-playwright-codex
@@ -49,6 +55,10 @@ dsh plugin --profile <name> add github:miqian-nomad/dsh-browser-playwright-codex
49
55
  dsh plugin --profile <name> add link:<absolute path to this directory>
50
56
  ```
51
57
 
58
+ **Compatibility.** Built and tested against DSH `0.2.0-rc.1` (`@deepseek-ai/*` peer ranges are deliberately
59
+ `*` so a newer harness still installs). After a harness upgrade, `npm run doctor` re-checks that every named
60
+ export this plugin imports still exists — that is the fast way to tell "the harness moved" from "the plugin broke".
61
+
52
62
  `lib/` ships in the repository (and there is deliberately no `prepare` hook), so a fresh clone loads
53
63
  without a build step — `npm run build` is only needed after editing `src/`.
54
64
 
package/SECURITY.md CHANGED
@@ -4,7 +4,7 @@ This plugin drives a **real browser with your login state**. That is the point o
4
4
  the thing worth being careful about. This file answers the two questions a reader actually has:
5
5
 
6
6
  1. **What can it do to me?**
7
- 2. **Why does dsh.so's scanner report "2 Critical"?**
7
+ 2. **Why does dsh.so's scanner report a Critical finding?**
8
8
 
9
9
  If you only read one line: **the two findings are the implementation of `browser_evaluate`, a tool
10
10
  this package ships switched off** — it is not registered at all until a deployment turns it on.
@@ -68,18 +68,22 @@ Nothing else.
68
68
 
69
69
  ---
70
70
 
71
- ## 2. Why the scanner reports "2 Critical"
71
+ ## 2. What the scanner reports, and which gate contains it
72
72
 
73
- dsh.so runs `dsh-plugin-vet` over the whole repository. On v0.4.9 it reports **2 Critical, 5 Warning,
74
- 26 Info** across 57 files — and it is **right about the capability**. Here is each one, with the gate
75
- that decides whether it is reachable at all.
73
+ dsh.so runs `dsh-plugin-vet` over the whole repository. Their scan of **v0.5.3 (2026-10-11)** reports
74
+ **1 Critical, 3 Warning, 22 Info** across 75 files — and it is **right about the capability**. Here is
75
+ each one, with the gate that decides whether it is reachable at all.
76
+
77
+ > For the record: their v0.4.9 scan — before this plugin was split into responsibility-scoped modules —
78
+ > reported 2 Critical, 5 Warning, 26 Info across 57 files. Counts move as the scanner and the code
79
+ > change; **their report is the live number**, and this table is a map, not a snapshot.
76
80
 
77
81
  | Level | Finding (as reported) | What it actually is | Reachable when |
78
82
  |---|---|---|---|
79
- | Critical | `new Function('return (' + expression + ')')` — `src/playwright.ts` (~:2041) and its `lib/` mirror | The body of **`browser_evaluate`**: it wraps your expression and runs it in the page. This is what "run JavaScript in the page" means; there is no way to implement it without dynamic evaluation. | Only when `allowEvaluate: true`. **The package ships `false`, so the tool is not registered.** |
80
- | Warning | `fs.writeFileSync` of a JSON blob (3 sites: `playwright.ts` ×2, `runtime-state.ts`) | Exporting the login state (`cookies` + `localStorage`) so a closed window can be reopened still logged in, and the small runtime state file behind the Settings switches. | Always — it is how login persistence works. The files live under your DSH home. |
83
+ | Critical | `new Function('return (' + expression + ')')` — reported in `lib/session.js` (source: `src/session.ts`) | The body of **`browser_evaluate`**: it wraps your expression and runs it in the page. This is what "run JavaScript in the page" means; there is no way to implement it without dynamic evaluation. | Only when `allowEvaluate: true`. **The package ships `false`, so the tool is not registered.** |
84
+ | Warning | `fs.writeFileSync` of a JSON blob (`lib/login-state.js`, `lib/runtime-state.js`) | Exporting the login state (`cookies` + `localStorage`) so a closed window can be reopened still logged in, and the small runtime state file behind the Settings switches. | Always — it is how login persistence works. The files live under your DSH home. |
81
85
  | Warning | HTTP request to a bare IP (`scripts/dsh-browser-verify.mjs`) | The acceptance suite starts a **local** `http.server` on `127.0.0.1` with a fixture page, so `npm run verify` needs no DSH and no internet. | Only when you run that script. |
82
- | Info ×26 | `localStorage` reads, `process.env` reads, dev-only probe scripts | Restoring login state; test hooks (`DSH_BROWSER_STATE_FILE`, `PLAYWRIGHT_DOWNLOAD_HOST`); and scripts under `scripts/analysis/` that the scanner itself marks *"Dev-only file — not executed at install/runtime"*. | — |
86
+ | Info ×22 | `localStorage` reads, `process.env` reads, hardcoded `127.0.0.1`, dev-only probe scripts | Restoring login state; test hooks (`DSH_BROWSER_STATE_FILE`, `PLAYWRIGHT_DOWNLOAD_HOST`); and scripts under `scripts/analysis/` that the scanner itself marks *"Dev-only file — not executed at install/runtime"*. | — |
83
87
 
84
88
  Two honest notes:
85
89
 
@@ -87,8 +91,9 @@ Two honest notes:
87
91
  your deployment turns it on. A scanner that reads code cannot see a runtime gate.
88
92
  - We did **not** try to hide the finding (moving the call, obfuscating it, or suppressing the rule).
89
93
  That would be deceiving the tool that is trying to protect users, and it would make this file a lie.
90
-
91
- ---
94
+ - Module names, not line numbers: this file used to cite a line number in `src/playwright.ts`, which the
95
+ 0.5.x split made meaningless (the code lives in `src/session.ts` now, and that file is a 55-line
96
+ entry point). File names survive refactors; line numbers do not.
92
97
 
93
98
  ## 3. How to check all of this yourself
94
99
 
@@ -0,0 +1,12 @@
1
+ import type { PlaywrightProvider } from './provider.ts';
2
+ /**
3
+ * Ensure the shared persistent context is alive (persistent mode). A
4
+ * profile-backed window is launched once and reused until closed by the
5
+ * last owner, idle disposal, or an external close; login state lives in
6
+ * the profile plus the exported state file, so reopening is seamless.
7
+ */
8
+ export declare function ensureContext(provider: PlaywrightProvider): Promise<import("playwright-core").BrowserContext>;
9
+ /** Remove stale Chrome singleton lock files inside the profile directory. */
10
+ export declare function clearChromeLocks(provider: PlaywrightProvider): Promise<void>;
11
+ /** Launch the browser once, probing the configured or auto-detected channel. */
12
+ export declare function ensureBrowser(provider: PlaywrightProvider): Promise<import("playwright-core").Browser>;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * 浏览器的生命:启动/复用持久化 context、清理 Chrome 锁、以及启动与 profile 相关参数。
3
+ * 窗口**焦点**行为(承载窗口是否最小化、要不要在后台建标签)在 window-focus.ts。
4
+ * @module dsh-browser-playwright-codex/browser-lifecycle
5
+ */
6
+ import { chromium } from 'playwright-core';
7
+ import path from 'node:path';
8
+ import fs from 'node:fs';
9
+ import { AUTO_CHANNELS, HUMANIZED_LAUNCH } from "./config.js";
10
+ import { SNAPSHOT_SCRIPT } from "./injected.js";
11
+ import { BrowserError, launchFailed } from "./errors.js";
12
+ /**
13
+ * Ensure the shared persistent context is alive (persistent mode). A
14
+ * profile-backed window is launched once and reused until closed by the
15
+ * last owner, idle disposal, or an external close; login state lives in
16
+ * the profile plus the exported state file, so reopening is seamless.
17
+ */
18
+ export async function ensureContext(provider) {
19
+ if (provider.context !== undefined) {
20
+ try {
21
+ const browser = provider.context.browser();
22
+ if (browser !== null && browser.isConnected())
23
+ return provider.context;
24
+ }
25
+ catch {
26
+ /* context died underneath us: relaunch */
27
+ }
28
+ provider.context = undefined;
29
+ }
30
+ fs.mkdirSync(provider.profileDir, { recursive: true });
31
+ const { executablePath, channel, headless } = provider.config.launch;
32
+ const headful = !headless;
33
+ const attempts = [];
34
+ const baseOptions = {
35
+ headless,
36
+ // Headful windows must render like a normal Chrome the user opened
37
+ // by hand: the page viewport equals the real window size and the
38
+ // system DPI scaling (e.g. 150%) is honoured. Playwright's default
39
+ // is the opposite — it pins every page to a fixed CSS viewport at
40
+ // deviceScaleFactor 1 via Emulation.setDeviceMetricsOverride, which
41
+ // on scaled displays overflows the window: content looks zoomed in
42
+ // and clipped. viewport: null disables that override entirely.
43
+ // Headless mode keeps the fixed viewport (no window to track).
44
+ ...(headless ? { viewport: provider.config.launch.viewport } : { viewport: null }),
45
+ ignoreHTTPSErrors: provider.config.launch.ignoreHTTPSErrors,
46
+ ...HUMANIZED_LAUNCH,
47
+ args: [
48
+ '--no-first-run',
49
+ '--no-default-browser-check',
50
+ // Open maximised like a human would, so the first paint fills
51
+ // the screen instead of a small default window.
52
+ ...(headful ? ['--start-maximized'] : []),
53
+ ...HUMANIZED_LAUNCH.args,
54
+ ],
55
+ };
56
+ const start = async (extra) => {
57
+ try {
58
+ provider.context = await chromium.launchPersistentContext(provider.profileDir, { ...baseOptions, ...extra });
59
+ }
60
+ catch (error) {
61
+ // A stale Chrome singleton lock from a dirty shutdown breaks a
62
+ // relaunch into the same profile; clear the lock files and
63
+ // retry once before surfacing the failure.
64
+ if (error instanceof Error && /user data directory|SingletonLock|ProcessSingleton/i.test(error.message)) {
65
+ await provider.clearChromeLocks();
66
+ provider.context = await chromium.launchPersistentContext(provider.profileDir, { ...baseOptions, ...extra });
67
+ }
68
+ else {
69
+ throw error;
70
+ }
71
+ }
72
+ const ctx = provider.context;
73
+ // External close (user closed the window, crash): forget every
74
+ // session handle bound to this context; the next acquire relaunches.
75
+ void ctx.on('close', () => {
76
+ if (provider.context !== ctx)
77
+ return;
78
+ provider.context = undefined;
79
+ for (const [key, entry] of [...provider.entries]) {
80
+ if (entry.context !== ctx)
81
+ continue;
82
+ if (entry.timer !== undefined)
83
+ clearTimeout(entry.timer);
84
+ provider.entries.delete(key);
85
+ }
86
+ });
87
+ ctx.on('page', (page) => provider.armDialogGuard(page));
88
+ try {
89
+ await provider.restoreState(ctx);
90
+ }
91
+ catch {
92
+ /* malformed state file must not block startup */
93
+ }
94
+ await ctx.addInitScript(SNAPSHOT_SCRIPT);
95
+ // The initial blank page predates addInitScript: reload so the
96
+ // snapshot engine and restored localStorage apply on first use.
97
+ for (const page of ctx.pages()) {
98
+ await page.reload({ waitUntil: 'domcontentloaded' }).catch(() => { });
99
+ }
100
+ return ctx;
101
+ };
102
+ if (executablePath !== undefined) {
103
+ try {
104
+ return await start({ executablePath });
105
+ }
106
+ catch (error) {
107
+ provider.launchError = error;
108
+ attempts.push('executablePath ' + executablePath);
109
+ }
110
+ }
111
+ else {
112
+ const channels = channel !== undefined && channel !== '' ? [channel] : [...AUTO_CHANNELS];
113
+ for (const candidate of channels) {
114
+ try {
115
+ return await start({ channel: candidate });
116
+ }
117
+ catch (error) {
118
+ provider.launchError = error;
119
+ attempts.push('channel ' + candidate);
120
+ }
121
+ }
122
+ }
123
+ const hint = 'Tried: ' +
124
+ attempts.join(', ') +
125
+ '. ' +
126
+ 'Install a Chromium-family browser, or run: npx playwright-core install chromium ' +
127
+ '(in restricted networks set PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright), ' +
128
+ 'or configure launch.channel / launch.executablePath.';
129
+ if (provider.launchError instanceof Error &&
130
+ /executable doesn't exist|not found/i.test(provider.launchError.message)) {
131
+ throw new BrowserError('NO_BROWSER', hint);
132
+ }
133
+ throw launchFailed(provider.launchError);
134
+ }
135
+ /** Remove stale Chrome singleton lock files inside the profile directory. */
136
+ export async function clearChromeLocks(provider) {
137
+ for (const lock of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
138
+ try {
139
+ fs.rmSync(path.join(provider.profileDir, lock), { force: true });
140
+ }
141
+ catch {
142
+ /* best-effort */
143
+ }
144
+ }
145
+ }
146
+ /** Launch the browser once, probing the configured or auto-detected channel. */
147
+ export async function ensureBrowser(provider) {
148
+ if (provider.browser !== undefined)
149
+ return provider.browser;
150
+ const { executablePath, channel, headless } = provider.config.launch;
151
+ const attempts = [];
152
+ if (executablePath !== undefined) {
153
+ try {
154
+ provider.browser = await chromium.launch({ executablePath, headless, ...HUMANIZED_LAUNCH });
155
+ return provider.browser;
156
+ }
157
+ catch (error) {
158
+ provider.launchError = error;
159
+ attempts.push('executablePath ' + executablePath);
160
+ }
161
+ }
162
+ else {
163
+ const channels = channel !== undefined && channel !== '' ? [channel] : [...AUTO_CHANNELS];
164
+ for (const candidate of channels) {
165
+ try {
166
+ provider.browser = await chromium.launch({ channel: candidate, headless, ...HUMANIZED_LAUNCH });
167
+ return provider.browser;
168
+ }
169
+ catch (error) {
170
+ provider.launchError = error;
171
+ attempts.push('channel ' + candidate);
172
+ }
173
+ }
174
+ }
175
+ const hint = 'Tried: ' +
176
+ attempts.join(', ') +
177
+ '. ' +
178
+ 'Install a Chromium-family browser, or run: npx playwright-core install chromium ' +
179
+ '(in restricted networks set PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright), ' +
180
+ 'or configure launch.channel / launch.executablePath.';
181
+ if (provider.launchError instanceof Error &&
182
+ /executable doesn't exist|not found/i.test(provider.launchError.message)) {
183
+ throw new BrowserError('NO_BROWSER', hint);
184
+ }
185
+ throw launchFailed(provider.launchError);
186
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 人机验证页识别:从快照里判断是否撞上 Cloudflare 之类的挑战页,并给出对应的提示文案。
3
+ * @module dsh-browser-playwright-codex/challenge
4
+ */
5
+ import type { ChallengeMatch, ChallengeSnapshot } from './tool-types.ts';
6
+ /**
7
+ * Detect anti-bot / human-verification challenge pages (Cloudflare
8
+ * interstitial, Turnstile, reCAPTCHA-style) from a snapshot's title, url and
9
+ * visible text. We do NOT try to solve them: the correct move for an agent is
10
+ * to hand the challenge to the human who can see the open window. Returns the
11
+ * engine name when matched, else null. Matching is conservative to avoid
12
+ * false positives on ordinary pages.
13
+ */
14
+ export declare function detectChallenge(snapshot: ChallengeSnapshot): ChallengeMatch | null;
15
+ /**
16
+ * Instruction appended above a snapshot when the page is an anti-bot
17
+ * challenge. The browser window is visible on the user's desktop: the agent
18
+ * should pause and ask the user to complete it by hand, then re-snapshot.
19
+ */
20
+ export declare const CHALLENGE_NOTE = "[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. The browser window is open on the user's desktop \u2014 tell the user a verification appeared, wait for them to finish it (a few seconds), then call browser_snapshot again. The clearance cookie is saved to the profile automatically and keeps working for the session.";
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Detect anti-bot / human-verification challenge pages (Cloudflare
3
+ * interstitial, Turnstile, reCAPTCHA-style) from a snapshot's title, url and
4
+ * visible text. We do NOT try to solve them: the correct move for an agent is
5
+ * to hand the challenge to the human who can see the open window. Returns the
6
+ * engine name when matched, else null. Matching is conservative to avoid
7
+ * false positives on ordinary pages.
8
+ */
9
+ export function detectChallenge(snapshot) {
10
+ const title = snapshot.title ?? '';
11
+ const url = snapshot.url ?? '';
12
+ const parts = [];
13
+ const walk = (nodes, depth) => {
14
+ for (const node of nodes) {
15
+ if (parts.length >= 60)
16
+ return;
17
+ if (node.name !== undefined && node.name !== '')
18
+ parts.push(node.name);
19
+ if (depth < 2 && node.children !== undefined && node.children.length > 0)
20
+ walk(node.children, depth + 1);
21
+ }
22
+ };
23
+ walk(snapshot.nodes ?? [], 0);
24
+ const text = [title, url, ...parts].join(' | ').slice(0, 900);
25
+ const saidCaptcha = parts.some((name) => /(captcha|turnstile|recaptcha|hcaptcha)/i.test(name));
26
+ if (/just a moment|attention required|checking (your )?browser|cf-chl-|请稍候|验证你的浏览器/i.test(text))
27
+ return { engine: 'Cloudflare', hint: 'Cloudflare is challenging the browser before letting the page load' };
28
+ if (/verify (you are|you're|that you are) (a )?human|人机验证|验证您(是|为)人类/i.test(text))
29
+ return { engine: 'human-verification', hint: 'the page is asking to verify a human visitor' };
30
+ if (/(captcha|turnstile|recaptcha|hcaptcha)/i.test(text) &&
31
+ (saidCaptcha || /(captcha|turnstile|recaptcha)/i.test(title)))
32
+ return { engine: 'captcha', hint: 'the page embedded a captcha-style challenge' };
33
+ return null;
34
+ }
35
+ /**
36
+ * Instruction appended above a snapshot when the page is an anti-bot
37
+ * challenge. The browser window is visible on the user's desktop: the agent
38
+ * should pause and ask the user to complete it by hand, then re-snapshot.
39
+ */
40
+ export const CHALLENGE_NOTE = "[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. The browser window is open on the user's desktop — tell the user a verification appeared, wait for them to finish it (a few seconds), then call browser_snapshot again. The clearance cookie is saved to the profile automatically and keeps working for the session.";
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 插件配置与常量:schema、启动参数、通道白名单、ref 形态。
3
+ * @module dsh-browser-playwright-codex/config
4
+ */
5
+ import z from '@deepseek-ai/schemastery';
6
+ /** Schemastery validation for {@link PlaywrightConfig}. */
7
+ /** Launch configuration for the Playwright provider. */
8
+ export interface PlaywrightConfig {
9
+ launch: {
10
+ /** Absolute path to a Chromium-family binary; takes precedence over channel. */
11
+ executablePath?: string;
12
+ /** Browser channel: chromium, chrome, msedge. Omitted = auto-detect in that order. */
13
+ channel?: string;
14
+ /** Run headful so the user can watch and rescue the browser by hand. */
15
+ headless: boolean;
16
+ /** Persistent mode: one shared profile-backed window whose login survives. */
17
+ persistent: boolean;
18
+ /** Profile directory for persistent mode; empty means ~/.dsh/browser-profiles/playwright. */
19
+ profileDir: string;
20
+ viewport: {
21
+ width: number;
22
+ height: number;
23
+ };
24
+ /** Per-action navigation/click timeout in milliseconds. */
25
+ navigationTimeoutMs: number;
26
+ /** Ignore HTTPS certificate errors. */
27
+ ignoreHTTPSErrors: boolean;
28
+ };
29
+ /** Host suffixes the browser may visit. Empty = any http(s) host. */
30
+ allowedDomains?: string[];
31
+ /** Close an idle session's browser context after this many milliseconds. 0 disables. */
32
+ idleTimeoutMs: number;
33
+ /** Maximum concurrent browser contexts; acquiring beyond it evicts the least recently used. */
34
+ maxSessions: number;
35
+ snapshot: {
36
+ /** 'legacy' keeps the injected DOM walker; 'aria' uses the official ariaSnapshot(mode:'ai') engine. */
37
+ engine: 'legacy' | 'aria';
38
+ maxNodes: number;
39
+ maxNameLength: number;
40
+ maxTextLength: number;
41
+ /** Incremental ref-diff mode; false = always full snapshots (zero behavioral change). */
42
+ diff: boolean;
43
+ };
44
+ }
45
+ /** Bounded page data the extraction consumer feeds to a model. */
46
+ export interface PageData {
47
+ readonly url: string;
48
+ readonly title: string;
49
+ readonly text: string;
50
+ readonly truncated: boolean;
51
+ readonly links: readonly {
52
+ readonly text: string;
53
+ readonly href: string;
54
+ }[];
55
+ readonly inputs: readonly {
56
+ readonly tag: string;
57
+ readonly type: string;
58
+ readonly name: string;
59
+ readonly value: string;
60
+ readonly label: string;
61
+ readonly checked: boolean | null;
62
+ }[];
63
+ }
64
+ /** Schemastery validation for {@link PlaywrightConfig}. */
65
+ export declare const Config: z<PlaywrightConfig>;
66
+ /** Channels probed in order when none is configured. */
67
+ export declare const AUTO_CHANNELS: string[];
68
+ /**
69
+ * Launch flags that strip Playwright's obvious automation markers (anti-bot
70
+ * detection layer L1). --enable-automation is what sets navigator.webdriver
71
+ * and the "controlled by automated test software" infobar; dropping it plus
72
+ * --disable-blink-features=AutomationControlled makes the browser present as
73
+ * a plain real Chrome, which it is. No fingerprint spoofing is added: real
74
+ * Chrome's own values are consistent by construction (spoofing would invent
75
+ * a device that never existed and draw MORE suspicion).
76
+ */
77
+ export declare const HUMANIZED_LAUNCH: {
78
+ ignoreDefaultArgs: string[];
79
+ args: string[];
80
+ };
81
+ export declare const REF_PATTERN: RegExp;
package/lib/config.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 插件配置与常量:schema、启动参数、通道白名单、ref 形态。
3
+ * @module dsh-browser-playwright-codex/config
4
+ */
5
+ import z from '@deepseek-ai/schemastery';
6
+ /** Schemastery validation for {@link PlaywrightConfig}. */
7
+ export const Config = z.object({
8
+ launch: z.object({
9
+ executablePath: z.string(),
10
+ channel: z.string(),
11
+ // Visible window by default: the agent drives a browser the user can
12
+ // watch and rescue by hand (login, captcha, dialogs).
13
+ headless: z.boolean().default(false),
14
+ // Persistent context mode: one shared profile-backed window whose
15
+ // login state survives close/reopen. false = legacy launch() mode.
16
+ persistent: z.boolean().default(true),
17
+ // Profile directory for persistent mode. Empty = ~/.dsh/browser-profiles/playwright.
18
+ profileDir: z.string().default(''),
19
+ // Headless only: a headful window must behave like a normal Chrome the
20
+ // user opened by hand, so the fixed CSS viewport is ignored there (see
21
+ // ensureContext) and the page tracks the real window size instead.
22
+ viewport: z
23
+ .object({ width: z.number().default(1280), height: z.number().default(800) })
24
+ .default({ width: 1280, height: 800 }),
25
+ navigationTimeoutMs: z.number().default(30000),
26
+ ignoreHTTPSErrors: z.boolean().default(false),
27
+ }),
28
+ allowedDomains: z.array(z.string()),
29
+ idleTimeoutMs: z.number().default(600000),
30
+ maxSessions: z.number().default(8),
31
+ snapshot: z
32
+ .object({
33
+ // 'legacy' keeps the injected DOM walker as the default: existing
34
+ // tests, verify and every consumer see byte-identical snapshots until
35
+ // a caller opts into the aria engine.
36
+ engine: z.union([z.const('legacy'), z.const('aria')]).default('legacy'),
37
+ maxNodes: z.number().default(500),
38
+ maxNameLength: z.number().default(120),
39
+ maxTextLength: z.number().default(300),
40
+ // diff=false keeps every return path a full snapshot: zero behavioral
41
+ // change unless a caller explicitly turns the incremental mode on.
42
+ diff: z.boolean().default(false),
43
+ })
44
+ .default({ engine: 'legacy', maxNodes: 500, maxNameLength: 120, maxTextLength: 300, diff: false }),
45
+ });
46
+ /** Channels probed in order when none is configured. */
47
+ export const AUTO_CHANNELS = ['chromium', 'chrome', 'msedge', 'edge'];
48
+ /**
49
+ * Launch flags that strip Playwright's obvious automation markers (anti-bot
50
+ * detection layer L1). --enable-automation is what sets navigator.webdriver
51
+ * and the "controlled by automated test software" infobar; dropping it plus
52
+ * --disable-blink-features=AutomationControlled makes the browser present as
53
+ * a plain real Chrome, which it is. No fingerprint spoofing is added: real
54
+ * Chrome's own values are consistent by construction (spoofing would invent
55
+ * a device that never existed and draw MORE suspicion).
56
+ */
57
+ export const HUMANIZED_LAUNCH = {
58
+ ignoreDefaultArgs: ['--enable-automation'],
59
+ args: ['--disable-blink-features=AutomationControlled'],
60
+ };
61
+ export const REF_PATTERN = /^e[0-9]+$/;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 提取工具的辅助:容错地解析模型返回的 JSON,以及为提取请求拼装提示词。
3
+ * @module dsh-browser-playwright-codex/extract
4
+ */
5
+ import type { JsonValue } from './tool-types.ts';
6
+ /** Strip markdown fences and whitespace, then parse one JSON document. */
7
+ export declare function parseJsonText(raw: string): JsonValue;
8
+ /**
9
+ * Prompt template for structured page extraction. `maxInputChars` bounds the page
10
+ * data handed to the auxiliary model — that model is billed on this prompt, so an
11
+ * unbounded page is a tax on every extraction.
12
+ */
13
+ export declare function extractionPrompt(instruction: string, data: unknown, maxInputChars: number): string;
package/lib/extract.js ADDED
@@ -0,0 +1,34 @@
1
+ /** Strip markdown fences and whitespace, then parse one JSON document. */
2
+ export function parseJsonText(raw) {
3
+ let text = raw.trim();
4
+ const fenced = /^\s*```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text);
5
+ if (fenced !== null) {
6
+ const inner = fenced[1];
7
+ if (inner !== undefined)
8
+ text = inner.trim();
9
+ }
10
+ try {
11
+ return JSON.parse(text);
12
+ }
13
+ catch {
14
+ throw new Error('browser_extract: the model output is not valid JSON: ' + text.slice(0, 500));
15
+ }
16
+ }
17
+ /**
18
+ * Prompt template for structured page extraction. `maxInputChars` bounds the page
19
+ * data handed to the auxiliary model — that model is billed on this prompt, so an
20
+ * unbounded page is a tax on every extraction.
21
+ */
22
+ export function extractionPrompt(instruction, data, maxInputChars) {
23
+ const page = JSON.stringify(data);
24
+ const bounded = page.length > maxInputChars
25
+ ? page.slice(0, maxInputChars) + ' [page data truncated at ' + String(maxInputChars) + ' characters]'
26
+ : page;
27
+ return ('Extract the following from the page data according to the instruction. ' +
28
+ 'Answer with ONLY one valid JSON value (object, array, or scalar), no prose, no markdown fences.\n\n' +
29
+ 'Instruction: ' +
30
+ instruction +
31
+ '\n\n' +
32
+ 'Page data (JSON):\n' +
33
+ bounded);
34
+ }
package/lib/index.d.ts CHANGED
@@ -14,7 +14,9 @@ export { SNAPSHOT_SCRIPT } from './injected.ts';
14
14
  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
- export { PlaywrightProvider, assertAllowedUrl, Config as PlaywrightConfigSchema } from './playwright.ts';
18
- export type { PlaywrightConfig, PageData } from './playwright.ts';
17
+ export { PlaywrightProvider } from './provider.ts';
18
+ export { assertAllowedUrl } from './url-policy.ts';
19
+ export { Config as PlaywrightConfigSchema } from './config.ts';
20
+ export type { PlaywrightConfig, PageData } from './config.ts';
19
21
  export type { BrowserNode, BrowserProvider, BrowserSession, BrowserSnapshot, LoadState, ScreenshotCapture, TabInfo, } from './types.ts';
20
22
  export type { ToolConfig } from './tool.ts';
package/lib/index.js CHANGED
@@ -9,4 +9,6 @@ export { ALWAYS_ON_SUFFIXES, ERROR_CODES, GATED_SUFFIXES, TOOL_SUFFIXES, registe
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
- export { PlaywrightProvider, assertAllowedUrl, Config as PlaywrightConfigSchema } from "./playwright.js";
12
+ export { PlaywrightProvider } from "./provider.js";
13
+ export { assertAllowedUrl } from "./url-policy.js";
14
+ export { Config as PlaywrightConfigSchema } from "./config.js";