dsh-browser-playwright-codex 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,83 @@
1
+ ## 0.9.0 — 2026-11-14
2
+
3
+ 一句话摘要:**一次调用跑一串步骤**——以前一套「打开 → 点登录 → 填表 → 等结果」要走七八次工具调用,每次都要模型想一遍、看一遍快照;现在多两个工具:`browser_run_script` 按步骤列表一次执行完,`browser_save_script` 把跑通过的流程存成命名脚本,下次一个调用就能复跑。**任一步失败就停在那一step**,把索引、动作、错误码和最后的页面状态交回来,让模型接着手工做——不是整条脚本报废。
4
+
5
+ 为什么是"步骤列表"而不是"让模型临时拼代码":成熟实现都这么分(Stagehand 缓存解析出的动作再确定性回放;browser-use 的 workflow-use 先录制再生成确定性工作流,失败回退给 agent;Playwright MCP 唯一能跑批量的入口直接叫 `browser_run_code_unsafe`,默认不当普通工具)。步骤列表能保住单步工具原有的校验——URL 白名单、ref 校验、密码字段拒填——而生成代码会把这些绕过去。
6
+
7
+ ### Added
8
+
9
+ - **`browser_run_script`**:一次执行 `navigate` / `snapshot` / `click` / `fill` / `press` / `hover` / `scroll` / `back` / `forward` / `wait` 十种步骤,最多 40 步;可以传内联 `steps`,也可以传已存脚本的 `name`(两个同时给直接报错,不猜)。
10
+ - **`browser_save_script`**:把步骤存成命名脚本(`~/.dsh/browser-scripts/<name>.json`,一个脚本一个文件),同名默认拒绝覆盖,要覆盖得显式 `overwrite: true`。
11
+ - **补齐的五个动作**:`press`(回车提交表单、Esc 关浮层)、`hover`(悬停出菜单/预览)、`scroll`(整页或指定滚动区;不给 `amount` 默认 600px,上限 20000px)、`back` / `forward`(历史前进后退)。少了这几样,真实流程很容易在「回车提交」或「滚到底加载更多」这一步断掉,只能退回逐步调用。
12
+ - **`wait` 支持 `urlContains`**:等 URL 里出现某个片段,最多 15 秒。这是唯一能兜住「点了但没跳转」的等待条件——按文字等会被脚本自己刚填进去的字满足,从而假成功(2026-11-14 在 npmjs 真实站点上抓到过)。
13
+ - **失败自愈(只重试一次)**:按可见文字定位不到元素时,重新拍一次快照再找一遍,找到就继续;给 `ref` 的步骤**不重试**(ref 属于产出它的那份文档,重拍只会得到新 ref)。重拍后仍找不到才判失败。
14
+ - **重渲染自愈(只重试一次)**:按文字解析出的 ref 在动作前被页面换掉时(`REF_NOT_FOUND`),重新拍快照、重新定位、把这一步再执行一次,并在结果里注明 `re-pointed after the page re-rendered`。触发条件是「文字定位 + `REF_NOT_FOUND`」两条同时成立:`REF_NOT_FOUND` 证明那一下没执行过,重试不会点两次;其它失败(超时等)一律不重试。给 `ref` 的步骤同样不重试。这条是**云服务器 Linux 上第一次跑真实脚本时抓出来的**——维基百科顶栏搜索框一获得焦点就被换成新节点,`fill` 返回的 ref 到下一步 `press` 就失效了;同一套脚本在 Windows 上没暴露。
15
+ - **文字定位**:步骤里的元素可以写 `ref`,也可以写可见文字 `text`;文字在**动作执行前**按最新快照解析(先精确匹配,再唯一子串),页面重渲染把 ref 弄失效也不影响。同名多个元素时**拒绝猜**,直接报「有 N 个同名元素,请改用 ref」。
16
+ - **失败即停并交接**:运行结果带 `failedAt` / `failedAction` / `failure` / `failureCode` 和最后可用的快照;模型从那里接手工,不用重放整条脚本。
17
+ - **取消可中断**:调用方中断后不再继续跑后面的步骤。
18
+
19
+ ### Changed
20
+
21
+ - **开场快照改成懒加载**:脚本第一步是 `navigate` 时不再先拍一次马上要被换掉的页面;只有真需要按文字定位时才拍。
22
+ - **脚本名与文件落盘**:名字只允许字母数字下划线短横(最长 64),落盘走「先写 `.tmp` 再 rename」,避免崩在中间留下半截脚本;读取时会**重新校验**步骤,手工改坏的文件当场报错而不是带病跑。
23
+ - **Chrome 顶部那条黄色警告条没了**:Playwright 默认给 Chromium 传 `--no-sandbox`,Chrome 就为它画出「您使用的是不受支持的命令行标记:--no-sandbox」横贯每个标签页。去掉这个参数**不够**——Chrome 会接着警告下一个参数(`--disable-blink-features`)——所以两件事一起做:显式开启渲染进程沙箱(`chromiumSandbox: true`)并加上 Chrome 自带的 `--test-type`。顺带是真安全改善:渲染进程沙箱从此真正生效,而不是一直关着。
24
+ - **沙箱被宿主拒绝时退回一次**:某些环境(没有桌面令牌的会话、受限容器、拦住 broker 的安全软件)不认沙箱,直接失败就等于「浏览器打不开」。现在会退回无沙箱再试一次,并在 provider 上记下 `sandboxFallback`——把「一条警告」升级成「硬故障」是不可接受的取舍。分类匹配刻意收窄,找不到可执行文件这类配置错误仍然立刻失败,不会白白多等一次 30 秒。
25
+
26
+ ### Tests
27
+
28
+ - 新增 `tests/script-steps.test.ts`(14 条):动作白名单、必填参数、`ref`/`text` 二选一、`wait` 只收一个条件、超长脚本、报错带步骤序号;新增动作的归一化形状(`press`/`hover`/`scroll`/`back`/`forward`)、`scroll` 默认值与上限、`describeStep` 逐条措辞。
29
+ - 新增 `tests/script-runner.test.ts`(16 条):全跑完、文字定位命中、同名歧义拒绝、找不到就停、`ref` 直达、中途失败带索引与快照且后续步骤不执行、条件等待走 `waitForCondition`、中断即停、`urlContains` 正反两向、五个新动作落到 session、定位自愈(成功 / 只重试一次 / 不碰 ref / 对 fill·press·hover 同样生效)、**重渲染自愈(失效后重指、显式 ref 不重试、动作超时不重试)**。
30
+ - 新增 `tests/script-store.test.ts`(7 条):存取往返、覆盖不留 `.tmp`、坏文件重校验、名字不许带路径、列表排序、目录不存在返回空。
31
+ - 新增 `tests/launch-flags.test.ts`(5 条):`--test-type` 必在、我们自己不加 `--no-sandbox`、三条启动路径都要开沙箱(静态守卫)、沙箱退回兜底存在且记在 provider 上、错误分类不越界(找不到可执行文件不触发重试)。
32
+ - 全套现在 **434 条 / 431 过 / 0 失败 / cancelled 0 / skipped 3 / todo 0**。
33
+
34
+ ### Verified
35
+
36
+ - `npm run build`、`npm run typecheck` 均为 0。
37
+ - `npm run cost` 复测:出厂 bundle 层仍是 **18 个工具**,全量注册仍是 **23 个**(工具数没变,只有描述变长);字符与 token 请以 `npm run cost` 的当场输出为准,README / PLUGIN-README / 中文说明里的表格已同步,由 `docs-consistency` 测试钉住。
38
+ - **真实站点端到端(2026-11-14,非单测)**:
39
+ - npmjs 搜索:`fill` → `click Search` → `wait urlContains="/search?q="`,4/4 落在 `/search?q=dsh-browser-playwright-codex`;同一条把等待条件改成永不出现时**停在第 4 步并报 `ACTION_TIMEOUT`**,不再假成功。
40
+ - GitHub 仓库页 → 点 `Actions` → 等 `/actions`:3/3,落在 All Workflow Runs。
41
+ - 百度首页(直连,不需要代理):`fill` ref → **`press Enter` 提交** → `wait urlContains="wd="` → `scroll down 600` → `scroll up 400` → `back` → `forward`:**7/7**,result 页 URL 带 `rsv_dl=tb_enter` 证明是回车提交;随后 `hover` 两个导航链接 3/3。
42
+ - httpbin 表单走已存脚本 `httpbin-form-submit` 复跑:5/5,落 `/post`,服务端回显 `custname` 与 `custtel` 对得上。
43
+ - Chrome 顶部警告条:改前浏览器进程带 `--no-sandbox`、每页顶部一条黄色警告;改后进程 `--no-sandbox` 消失、带 `--test-type`,截图里只剩 npm 网站自己的通知条。
44
+ - 失败即停的现场交接在真实站点上复现过:某步指向不存在的按钮时停在那一步,后续步骤未执行。
45
+ - **云服务器(Linux)端到端,2026-11-14**:在 Ubuntu 24.04 + Node 24 + Chrome 157 的云主机上,装好本包依赖后用 `PlaywrightProvider` 直接驱动真实 Chrome 跑脚本(不经过 DSH、不需要模型 key):
46
+ - 启动参数现场核对:`--no-sandbox` **没有**、`--test-type` **有**、`--disable-blink-features` 有,`sandboxFallback` 为 false —— 说明在这台 Linux 上沙箱本来就可用,兜底没有被触发(兜底本身由单元测试钉住)。
47
+ - 维基百科:`navigate → fill "Search Wikipedia" → press Enter → wait urlContains "/wiki/DeepSeek" → scroll down 400`,**5/5**,其中 press 那步打出 `re-pointed after the page re-rendered`。
48
+ - 反向用例:`wait urlContains "/never-here"` 在 15 秒后**如实超时失败**,没有假装成功。
49
+ - 历史:`navigate → snapshot → scroll → back` **4/4**。
50
+ - **这一趟抓到一个真 bug**:维基百科顶栏搜索框获得焦点即被替换,`fill` 返回快照里的 ref 到下一步 `press` 已失效,Windows 上没暴露、Linux 上必现。已按上面的「重渲染自愈」修好,并补了 3 条单元测试(失效后重指成功、显式 ref 不重试、动作超时不重试)。
51
+
52
+ ## 0.8.3 — 2026-11-12
53
+
54
+ 一句话摘要:**插件可以走代理了**——以前它启动的浏览器只能直连,凡是要代理的站点(维基、GitHub 网页、大量国外站)一律打不开。现在多一个 `launch.proxy` 配置项和设置页里的「代理」输入框;**默认留空 = 直连,行为与之前完全一样**。实测同一台机器:维基直连 31 秒超时,走代理 1 秒返回。
55
+
56
+ ### Added
57
+
58
+ - **`launch.proxy`**:填 `http://host:port`、`https://host:port` 或 `socks5://host:port`,只写 `host:port` 会按 Playwright 的规则补成 `http://`。代理交给**浏览器启动参数**(`launchOptions.proxy`),整个浏览器走代理——不是在某个请求上加头。
59
+ - **设置页「代理」输入框**:放在「已登录的站点」正前方(这张卡的最后一块),不再散落在中间。占位提示 `http://host:port`,示例用保留域名 `http://proxy.example:8080`,**不写任何本机真实地址**;默认值为空。
60
+ - **强制绕过本机地址**:配置代理时自动带上 `bypass: localhost,127.0.0.1`。不加这个,插件自己的本地页面(verify、本地普查、任何本地服务)会被塞进代理而连不上。
61
+ - **地址写错当场报错,绝不静默回退直连**:缺 scheme 时按 `host:port` 补 http;端口必须 1–65535;带路径/查询/片段、未知 scheme 都拒绝,并给出「写成 `scheme://host:port`」这句人话。静默回退会把"代理没生效"伪装成"网站打不开",比报错更难查。
62
+ - **不支持需要账号密码的代理**:带账号密码的地址在校验阶段就拒。两个理由——密码会明文落在用户的 `cordis.patch.yml` 里;而且 Chromium/Playwright 本身不支持 SOCKS5 认证(`Browser does not support socks5 proxy authentication`)。宁可少一个能力,不埋一个明文密码。
63
+
64
+ ### Changed
65
+
66
+ - **保存代理只改一行**:`playwright.launch` 下的 `proxy:` 这一行,与三个能力闸门同一个模式(已知键、按行改)。其它键(`profileDir` / `channel` / `viewport` / `maxSessions` / `toolPrefix` / `maxWaitMs` 等)**逐字节不动**;清空 = 删掉那一行(留空=直连必须是干净的,不能留空值);结构认不出就**拒绝写入**,绝不整块重渲染。
67
+ - **生效时机**:代理是浏览器启动参数,插件在加载时读配置,所以**改完要重启 DSH 才生效**;设置页保存成功后会直接提示这一点。
68
+
69
+ ### Tests
70
+
71
+ - 新增 `tests/proxy.test.ts`(9 条):空配置不带 proxy;填了带 server + bypass;`host:port` 短写补 http;端口缺失/非数字/0/65536;认证代理拒绝;未知 scheme 与带路径拒绝;启动路径静态守卫(防止只加配置没接上);**代理不影响 `allowedDomains` 判定**。
72
+ - `tests/settings-card.test.ts` 新增 4 条:保存后 `launch.proxy` 出现且其它键完全不变(含 `profileDir`);清空后那一行被删;非法地址 400 且文件不变;重复保存同一值幂等无重复键。
73
+ - 全套现在 **390 条 / 387 过 / 0 失败 / cancelled 0 / skipped 3 / todo 0**。
74
+
75
+ ### Verified
76
+
77
+ - **走代理真跑 live 三条:3/3 通过**。`example.com` 两条照常;`wikipedia REST summary` 在直连时 31 秒 `NAVIGATION_TIMEOUT` 失败,配代理后 **1.0 秒通过**——这就是这次改动的直接证据(此前 0.8.2 发布时这条是 2/3)。
78
+ - verify 60 项全过、preflight 0 硬错误、打包 101 个文件(新增 `lib/proxy.js` / `lib/proxy.d.ts`)。
79
+ - 已知限制:设置页里没有"测试代理连通性"的按钮;填错端口但格式合法时,要到实际启动浏览器才会发现连不上。
80
+
1
81
  ## 0.8.2 — 2026-11-12
2
82
 
3
83
  一句话摘要:**发版门禁不再会"假绿",快照引擎第一次在真实网站上被量过**——preflight 过去只读 `ℹ fail`(而且"fail 0"是写死的字符串),被取消的用例看不出、也不报;设置页这个全仓最大的零测试模块补上了走真实 HTTP 的测试,顺手修掉"已有未标记装载行时写出第二行"的坏文件路径;智能模式(aria)的解析器与可交互集对齐后,130 个任务页的降级从 18 页降到 3 页。
package/README.md CHANGED
@@ -123,7 +123,7 @@ Or pin a binary: `launch.executablePath` / `launch.channel` in the plugin config
123
123
 
124
124
  ## Tools
125
125
 
126
- The default `toolPrefix` is `browser_`. Every action returns a fresh snapshot, so refs always come from the latest result. The registered set is capability-gated: `browser_evaluate`, `browser_cdp` and `browser_extract` appear only when their capability is switched on (their schemas are resident in the system prompt on every turn, so a tool that could only answer "disabled" would be pure prompt cost). Count the live surface with `npm run cost` — the shipped `cordis.patch.yml` keeps **both power gates off** (since 0.4.7), so a profile mounting this bundle sees **16 tools**; 17 or 18 once your deployment opts into `evaluate` / `cdp`, 19 with `diagnostics: true`, 21 once `extract` is configured. How to opt in: paste the snippet under Known Limitations below, or use the built-in Settings card.
126
+ The default `toolPrefix` is `browser_`. Every action returns a fresh snapshot, so refs always come from the latest result. The registered set is capability-gated: `browser_evaluate`, `browser_cdp` and `browser_extract` appear only when their capability is switched on (their schemas are resident in the system prompt on every turn, so a tool that could only answer "disabled" would be pure prompt cost). Count the live surface with `npm run cost` — the shipped `cordis.patch.yml` keeps **both power gates off** (since 0.4.7), so a profile mounting this bundle sees **18 tools**; 19 or 20 once your deployment opts into `evaluate` / `cdp`, 22 with `diagnostics: true`, 23 once `extract` is configured. How to opt in: paste the snippet under Known Limitations below, or use the built-in Settings card.
127
127
 
128
128
  | Tool | Purpose |
129
129
  |---|---|
@@ -166,6 +166,8 @@ parameter or a wrong required flag fails the test, so it cannot drift from the c
166
166
  - `browser_network_requests` — `urlContains`?, `failedOnly`?, `limit`?
167
167
  - `browser_open_tab` — `url`*, `waitFor`?
168
168
  - `browser_press` — `ref`*, `key`*
169
+ - `browser_run_script` — `name`?, `steps`?
170
+ - `browser_save_script` — `name`*, `steps`*, `overwrite`?
169
171
  - `browser_screenshot` — `fullPage`?, `ref`?
170
172
  - `browser_scroll` — `direction`*, `amount`?, `ref`?
171
173
  - `browser_snapshot` — `interactiveOnly`?
@@ -395,9 +397,9 @@ Measured with ``npm run cost`` (per-turn, resident in the system prompt):
395
397
 
396
398
  | Surface | Tools | Schema characters | ≈ tokens/turn |
397
399
  |---|---|---|---|
398
- | Schema defaults (`Config({})`, no patch layer) | 16 | 11,283 | ≈ 3,134 |
399
- | Shipped bundle layer (this package's `cordis.patch.yml`: both power gates off) | 16 | 11,283 | ≈ 3,134 |
400
- | Everything registered (`registerDisabledTools: true`, plus `extract`) | 21 | 16,146 | ≈ 4,485 |
400
+ | Schema defaults (`Config({})`, no patch layer) | 18 | 14,192 | ≈ 3,942 |
401
+ | Shipped bundle layer (this package's `cordis.patch.yml`: both power gates off) | 18 | 14,192 | ≈ 3,942 |
402
+ | Everything registered (`registerDisabledTools: true`, plus `extract`) | 23 | 19,055 | ≈ 5,293 |
401
403
 
402
404
  This paragraph describes the 0.6.0 change; the table above is today's measurement. Three things changed
403
405
  there, and only one of them was about size. (1) The click family was rewritten so each rule is stated once
@@ -498,6 +500,16 @@ MIT — **except `assets/swarm-rebuild.js`** (third-party work, shipped in the p
498
500
 
499
501
  (此列表只记 **minor 里程碑**;每个补丁版本见 [CHANGELOG.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/CHANGELOG.md)。)
500
502
 
503
+ ### 0.9.0(2026-11-14)
504
+
505
+ - **一次调用跑一串步骤(新增两个工具)**:`browser_run_script` 按步骤列表(navigate / snapshot / click / fill / press / hover / scroll / back / forward / wait,最多 40 步)一次执行完;`browser_save_script` 把跑通过的流程存成命名脚本(`~/.dsh/browser-scripts/*.json`),下次一个调用复跑。以前一套「打开 → 登录 → 填表 → 等结果」要七八次调用,现在一次。
506
+ - **失败即停并交回现场**:任一步失败就停在那一 step,返回索引、动作、错误码和最后可用的快照,模型接着手工做;不是整条脚本报废。
507
+ - **失败自愈只重试一次**:按可见文字定位不到元素时重拍一次快照再找;给 `ref` 的步骤不重试(ref 属于产出它的那份文档)。
508
+ - **`wait` 支持 `urlContains`**:等 URL 出现某个片段,最多 15 秒——唯一能兜住「点了但没跳转」的条件,按文字等会被脚本自己刚填进去的字满足,从而假成功。
509
+ - **文字定位**:步骤可以按可见文字 `text` 找元素,在动作执行前按最新快照解析,页面重渲染把 ref 弄失效也不影响;同名多个元素时拒绝猜,直接报「有 N 个同名,改用 ref」。
510
+ - **Chrome 顶部那条「不受支持的命令行标记」警告条不再出现**:显式开启渲染进程沙箱并加 Chrome 自带的 `--test-type`。只去掉 `--no-sandbox` 不够——Chrome 会接着警告下一个参数。宿主拒绝沙箱时自动退回一次,不会因此打不开浏览器。
511
+ - **为什么不做「让模型拼代码」**:成熟实现都切成「探索一次 → 确定性回放」(Stagehand 缓存解析出的动作、browser-use 的 workflow-use 录制后生成工作流、Playwright MCP 唯一批量入口直接叫 `run_code_unsafe`)。步骤列表能保住单步工具原有的 URL 白名单、ref 校验和密码拒填,生成代码会绕过去。
512
+
501
513
  ### 0.8.0(2026-11-10)
502
514
 
503
515
  - **动效资产移出发布包(破坏性)**:`assets/swarm-rebuild.js` 不再随 npm 包分发,`package.json` 的 `files` 移除了 `assets` 相关两条。原因是那套资产是第三方作品、授权状态不允许再分发 —— 与其在授权上纠缠,不如不让它进 tarball。仓库里文件仍在,`npm run swarm:check` 的 SHA 校验照旧有效。
@@ -7,10 +7,25 @@ import { chromium } from 'playwright-core';
7
7
  import path from 'node:path';
8
8
  import fs from 'node:fs';
9
9
  import { AUTO_CHANNELS, HUMANIZED_LAUNCH } from "./config.js";
10
+ import { proxyLaunchOption } from "./proxy.js";
10
11
  import { SNAPSHOT_SCRIPT } from "./injected.js";
11
12
  import { BrowserError, launchFailed } from "./errors.js";
12
13
  import { forgetOnBrowserClose, showWelcome } from "./swarm-intro.js";
13
14
  import { swarmBootScript } from "./swarm-curtain.js";
15
+ /**
16
+ * Does this launch failure point at the sandbox rather than at the browser?
17
+ *
18
+ * Chromium reports a refused renderer sandbox in a handful of shapes and none
19
+ * of them contains the word "sandbox": the broker fails, the child exits before
20
+ * DevTools answers, and Playwright surfaces it as a closed target or a generic
21
+ * launch failure. The match stays narrow on purpose — a wrong `executablePath`
22
+ * ("executable doesn't exist") must fail fast instead of costing a second
23
+ * 30-second attempt to reach the same answer.
24
+ */
25
+ function sandboxMayBeTheCause(error) {
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ return /sandbox|Target page, context or browser has been closed|Target closed|browser has crashed|Failed to launch/i.test(message);
28
+ }
14
29
  /**
15
30
  * Ensure the shared persistent context is alive (persistent mode). A
16
31
  * profile-backed window is launched once and reused until closed by the
@@ -72,6 +87,15 @@ async function launchContext(provider) {
72
87
  // Headless mode keeps the fixed viewport (no window to track).
73
88
  ...(headless ? { viewport: provider.config.launch.viewport } : { viewport: null }),
74
89
  ignoreHTTPSErrors: provider.config.launch.ignoreHTTPSErrors,
90
+ // Playwright adds --no-sandbox unless this is exactly `true`, and Chrome
91
+ // answers --no-sandbox with an infobar across the top of every window
92
+ // ("您使用的是不受支持的命令行标记:--no-sandbox"). Passing it also keeps the
93
+ // renderer sandbox on, which is the safer state anyway: the bar is the
94
+ // symptom, not the reason.
95
+ chromiumSandbox: true,
96
+ // Proxy at the browser level (launchOptions.proxy): request headers cannot route a whole
97
+ // browser. Empty proxy yields {} so the default stays a direct connection.
98
+ ...proxyLaunchOption(provider.config.launch.proxy),
75
99
  ...HUMANIZED_LAUNCH,
76
100
  args: [
77
101
  '--no-first-run',
@@ -83,8 +107,13 @@ async function launchContext(provider) {
83
107
  ],
84
108
  };
85
109
  const start = async (extra) => {
110
+ const attempt = (sandbox) => chromium.launchPersistentContext(provider.profileDir, {
111
+ ...baseOptions,
112
+ ...extra,
113
+ ...(sandbox ? {} : { chromiumSandbox: false }),
114
+ });
86
115
  try {
87
- provider.context = await chromium.launchPersistentContext(provider.profileDir, { ...baseOptions, ...extra });
116
+ provider.context = await attempt(true);
88
117
  provider.launchedHeadless = headless;
89
118
  }
90
119
  catch (error) {
@@ -103,7 +132,18 @@ async function launchContext(provider) {
103
132
  if (error instanceof Error &&
104
133
  /user data directory|SingletonLock|ProcessSingleton|Timeout \d+ms exceeded/i.test(error.message)) {
105
134
  await provider.clearChromeLocks();
106
- provider.context = await chromium.launchPersistentContext(provider.profileDir, { ...baseOptions, ...extra });
135
+ provider.context = await attempt(true);
136
+ provider.launchedHeadless = headless;
137
+ }
138
+ else if (sandboxMayBeTheCause(error)) {
139
+ // The sandbox is a capability of the host, not of this plugin: a
140
+ // Windows session without the desktop token it needs (some service
141
+ // contexts), a locked-down container, or an AV product that blocks the
142
+ // broker can all refuse it. Losing the sandbox costs a security layer;
143
+ // losing the launch costs the whole browser — so step down, and only
144
+ // once, rather than turning a warning into a hard failure.
145
+ provider.sandboxFallback = true;
146
+ provider.context = await attempt(false);
107
147
  provider.launchedHeadless = headless;
108
148
  }
109
149
  else {
@@ -224,9 +264,31 @@ async function launchBrowser(provider) {
224
264
  const { executablePath, channel } = provider.config.launch;
225
265
  const headless = provider.desiredHeadless();
226
266
  const attempts = [];
267
+ /**
268
+ * Launch with the renderer sandbox on, and step down to no-sandbox once when
269
+ * the host refuses it. See {@link sandboxMayBeTheCause}: the sandbox is the
270
+ * host's capability, so refusing to launch without it would turn one blocked
271
+ * syscall into "the plugin does not work".
272
+ */
273
+ const launchWithSandboxFallback = async (options) => {
274
+ try {
275
+ return await chromium.launch({ ...options, chromiumSandbox: true });
276
+ }
277
+ catch (error) {
278
+ if (!sandboxMayBeTheCause(error))
279
+ throw error;
280
+ provider.sandboxFallback = true;
281
+ return await chromium.launch({ ...options, chromiumSandbox: false });
282
+ }
283
+ };
227
284
  if (executablePath !== undefined) {
228
285
  try {
229
- provider.browser = await chromium.launch({ executablePath, headless, ...HUMANIZED_LAUNCH });
286
+ provider.browser = await launchWithSandboxFallback({
287
+ executablePath,
288
+ headless,
289
+ ...HUMANIZED_LAUNCH,
290
+ ...proxyLaunchOption(provider.config.launch.proxy),
291
+ });
230
292
  provider.launchedHeadless = headless;
231
293
  return provider.browser;
232
294
  }
@@ -239,7 +301,12 @@ async function launchBrowser(provider) {
239
301
  const channels = channel !== undefined && channel !== '' ? [channel] : [...AUTO_CHANNELS];
240
302
  for (const candidate of channels) {
241
303
  try {
242
- provider.browser = await chromium.launch({ channel: candidate, headless, ...HUMANIZED_LAUNCH });
304
+ provider.browser = await launchWithSandboxFallback({
305
+ channel: candidate,
306
+ headless,
307
+ ...HUMANIZED_LAUNCH,
308
+ ...proxyLaunchOption(provider.config.launch.proxy),
309
+ });
243
310
  provider.launchedHeadless = headless;
244
311
  return provider.browser;
245
312
  }
package/lib/client.js CHANGED
@@ -403,6 +403,13 @@ window.__ModuleLoader__.load({
403
403
  const [logins, setLogins] = useState(null)
404
404
  const [headless, setHeadless] = useState(null)
405
405
  const [headlessUserChosen, setHeadlessUserChosen] = useState(false)
406
+ // 代理(浏览器启动参数):'' = 直连。
407
+ const [proxy, setProxy] = useState('')
408
+ const [proxyDraft, setProxyDraft] = useState('')
409
+ const [proxyBusy, setProxyBusy] = useState(false)
410
+ const [proxyMsg, setProxyMsg] = useState(null)
411
+ // 输入框有焦点时,5 秒一次的轮询不许覆盖用户正在打的字。
412
+ const proxyFocusedRef = useRef(false)
406
413
  const [showAll, setShowAll] = useState(false)
407
414
  const [openGroups, setOpenGroups] = useState([])
408
415
  const [hiddenDomains, setHiddenDomains] = useState(readHiddenDomains())
@@ -436,6 +443,10 @@ window.__ModuleLoader__.load({
436
443
  if (typeof s.swarmPersistPlayed === 'boolean') setSwarmPersist(s.swarmPersistPlayed)
437
444
  if (typeof s.headless === 'boolean') setHeadless(s.headless)
438
445
  setHeadlessUserChosen(s.headlessUserChosen === true)
446
+ if (typeof s.proxy === 'string') {
447
+ setProxy(s.proxy)
448
+ if (!proxyFocusedRef.current) setProxyDraft(s.proxy)
449
+ }
439
450
  setGates({
440
451
  allowEvaluate: typeof s.allowEvaluate === 'boolean' ? s.allowEvaluate : null,
441
452
  allowCdp: typeof s.allowCdp === 'boolean' ? s.allowCdp : null,
@@ -573,6 +584,30 @@ window.__ModuleLoader__.load({
573
584
  }
574
585
  }
575
586
 
587
+ // 保存代理:只改 cordis.patch.yml 里 launch.proxy 那一行;空 = 删掉(直连)。
588
+ const saveProxy = async () => {
589
+ if (proxyBusy) return
590
+ setProxyBusy(true)
591
+ setProxyMsg(null)
592
+ try {
593
+ const r = await api('/proxy', 'POST', { proxy: proxyDraft })
594
+ if (r && r.error) {
595
+ setProxyMsg({ kind: 'error', text: r.error.message || r.error.code || '保存失败' })
596
+ return
597
+ }
598
+ const saved = typeof r.proxy === 'string' ? r.proxy : ''
599
+ setProxy(saved)
600
+ setProxyDraft(saved)
601
+ setProxyMsg({
602
+ kind: 'ok',
603
+ text: (saved === '' ? '已保存:留空 = 直连。' : '已保存:' + saved + '。') + '重启 DSH 后生效。',
604
+ })
605
+ } catch (error) {
606
+ setProxyMsg({ kind: 'error', text: '保存失败:' + (error?.message ?? String(error)) })
607
+ } finally {
608
+ setProxyBusy(false)
609
+ }
610
+ }
576
611
  const chooseWindow = async (nextHeadless) => {
577
612
  if (headless === null || busy || nextHeadless === headless) return
578
613
  setBusy(true)
@@ -1153,6 +1188,68 @@ window.__ModuleLoader__.load({
1153
1188
  )
1154
1189
  : null,
1155
1190
 
1191
+ // ---- 代理(浏览器启动参数)----
1192
+ // 放在「已登录的站点」正前方(用户要求的最后一块),样式与其它分组一致:
1193
+ // 标题行 + 一整块内容。留空 = 直连;改了要重启 DSH 才生效(保存成功时会说明)。
1194
+ h('div', { key: 'proxyGroup', style: S.group }, ['代理']),
1195
+ h('div', { key: 'proxyBody', style: { ...S.list, marginTop: 8 } }, [
1196
+ h('div', { key: 'row', style: { display: 'flex', alignItems: 'center', gap: 8 } }, [
1197
+ h('input', {
1198
+ key: 'input',
1199
+ type: 'text',
1200
+ value: proxyDraft,
1201
+ placeholder: 'http://host:port',
1202
+ disabled: proxyBusy,
1203
+ spellCheck: false,
1204
+ onFocus: () => {
1205
+ proxyFocusedRef.current = true
1206
+ },
1207
+ onBlur: () => {
1208
+ proxyFocusedRef.current = false
1209
+ },
1210
+ onChange: (e) => {
1211
+ setProxyDraft(e.target.value)
1212
+ setProxyMsg(null)
1213
+ },
1214
+ style: {
1215
+ flex: '1 1 auto',
1216
+ minWidth: 160,
1217
+ height: 28,
1218
+ padding: '0 10px',
1219
+ border: '0.5px solid ' + T.borderL3,
1220
+ borderRadius: 14,
1221
+ background: 'transparent',
1222
+ font: 'inherit',
1223
+ fontSize: 12,
1224
+ lineHeight: '18px',
1225
+ color: T.primary,
1226
+ },
1227
+ }),
1228
+ h(
1229
+ Btn,
1230
+ {
1231
+ key: 'save',
1232
+ disabled: proxyBusy || proxyDraft === proxy,
1233
+ },
1234
+ proxyBusy ? '保存中…' : '保存',
1235
+ ),
1236
+ ]),
1237
+ h(
1238
+ 'p',
1239
+ { key: 'hint', style: S.note },
1240
+ '留空 = 直连。要填的话写成 http://主机:端口 或 socks5://主机:端口,例如 http://proxy.example:8080;本机页面不走代理。不支持需要账号密码的代理。',
1241
+ ),
1242
+ proxyMsg
1243
+ ? h(
1244
+ 'p',
1245
+ {
1246
+ key: 'msg',
1247
+ style: { ...S.note, color: proxyMsg.kind === 'error' ? T.error : T.success },
1248
+ },
1249
+ proxyMsg.text,
1250
+ )
1251
+ : null,
1252
+ ]),
1156
1253
  // ---- 已登录站点(默认收起) ----
1157
1254
  h(
1158
1255
  'div',
package/lib/config.d.ts CHANGED
@@ -25,6 +25,11 @@ export interface PlaywrightConfig {
25
25
  navigationTimeoutMs: number;
26
26
  /** Ignore HTTPS certificate errors. */
27
27
  ignoreHTTPSErrors: boolean;
28
+ /**
29
+ * Proxy for the whole browser, e.g. `http://proxy.example:8080` (http / https / socks5).
30
+ * Empty = connect directly — the default, and the previous behaviour.
31
+ */
32
+ proxy?: string;
28
33
  };
29
34
  /** Host suffixes the browser may visit. Empty = any http(s) host. */
30
35
  allowedDomains?: string[];
@@ -99,6 +104,12 @@ export declare const AUTO_CHANNELS: string[];
99
104
  * a plain real Chrome, which it is. No fingerprint spoofing is added: real
100
105
  * Chrome's own values are consistent by construction (spoofing would invent
101
106
  * a device that never existed and draw MORE suspicion).
107
+ *
108
+ * `--test-type` is Chrome's own switch for "this window is not a normal user
109
+ * session": it suppresses the yellow bar Chrome draws across the top of every
110
+ * tab for each command-line flag it dislikes ("您使用的是不受支持的命令行标记:…").
111
+ * That bar names whatever flag it saw, so dropping the flag that triggered it
112
+ * only makes Chrome complain about the next one — the bar itself has to go.
102
113
  */
103
114
  export declare const HUMANIZED_LAUNCH: {
104
115
  ignoreDefaultArgs: string[];
package/lib/config.js CHANGED
@@ -27,6 +27,11 @@ export const Config = z.object({
27
27
  // one second; the default stays 30s.
28
28
  navigationTimeoutMs: z.number().min(1000).default(30000),
29
29
  ignoreHTTPSErrors: z.boolean().default(false),
30
+ // Proxy for the whole browser (launchOptions.proxy), e.g. http://proxy.example:8080.
31
+ // Empty = direct, which is the default and keeps every existing deployment unchanged.
32
+ // Validation lives in src/proxy.ts so a bad address fails loudly instead of silently
33
+ // connecting directly (which would look like "the site is down").
34
+ proxy: z.string().default(''),
30
35
  }),
31
36
  allowedDomains: z.array(z.string()),
32
37
  idleTimeoutMs: z.number().default(600000),
@@ -75,9 +80,15 @@ export const AUTO_CHANNELS = ['chromium', 'chrome', 'msedge', 'edge'];
75
80
  * a plain real Chrome, which it is. No fingerprint spoofing is added: real
76
81
  * Chrome's own values are consistent by construction (spoofing would invent
77
82
  * a device that never existed and draw MORE suspicion).
83
+ *
84
+ * `--test-type` is Chrome's own switch for "this window is not a normal user
85
+ * session": it suppresses the yellow bar Chrome draws across the top of every
86
+ * tab for each command-line flag it dislikes ("您使用的是不受支持的命令行标记:…").
87
+ * That bar names whatever flag it saw, so dropping the flag that triggered it
88
+ * only makes Chrome complain about the next one — the bar itself has to go.
78
89
  */
79
90
  export const HUMANIZED_LAUNCH = {
80
91
  ignoreDefaultArgs: ['--enable-automation'],
81
- args: ['--disable-blink-features=AutomationControlled'],
92
+ args: ['--disable-blink-features=AutomationControlled', '--test-type'],
82
93
  };
83
94
  export const REF_PATTERN = /^e[0-9]+$/;
package/lib/contract.d.ts CHANGED
@@ -9,14 +9,14 @@
9
9
  * Tool name suffixes in registration order; a wire name is `prefix + suffix`.
10
10
  * Gated suffixes are kept in the same list so the full surface stays visible.
11
11
  */
12
- export declare const TOOL_SUFFIXES: readonly ["navigate", "snapshot", "click", "dialog", "fill", "press", "scroll", "back", "forward", "wait", "tabs", "switch_tab", "open_tab", "close_tab", "screenshot", "extract", "evaluate", "cdp", "console_messages", "network_requests", "close"];
12
+ export declare const TOOL_SUFFIXES: readonly ["navigate", "snapshot", "click", "dialog", "fill", "press", "scroll", "back", "forward", "wait", "run_script", "save_script", "tabs", "switch_tab", "open_tab", "close_tab", "screenshot", "extract", "evaluate", "cdp", "console_messages", "network_requests", "close"];
13
13
  /** One tool name suffix. */
14
14
  export type ToolSuffix = (typeof TOOL_SUFFIXES)[number];
15
15
  /**
16
16
  * Suffixes registered unconditionally. They are always in the system prompt,
17
17
  * so this list is also the resident schema cost of the plugin.
18
18
  */
19
- export declare const ALWAYS_ON_SUFFIXES: readonly ["navigate", "snapshot", "click", "dialog", "fill", "press", "scroll", "back", "forward", "wait", "tabs", "switch_tab", "open_tab", "close_tab", "screenshot", "close"];
19
+ export declare const ALWAYS_ON_SUFFIXES: readonly ["navigate", "snapshot", "click", "dialog", "fill", "press", "scroll", "back", "forward", "wait", "run_script", "save_script", "tabs", "switch_tab", "open_tab", "close_tab", "screenshot", "close"];
20
20
  /**
21
21
  * Suffixes registered only when their capability is enabled (or configured):
22
22
  * a tool the deployment disabled can only error, so shipping its schema would
@@ -72,6 +72,10 @@ export declare const ERROR_CODES: {
72
72
  readonly CDP_DISABLED: "CDP_DISABLED";
73
73
  readonly CDP_DENIED: "CDP_DENIED";
74
74
  readonly CDP_ERROR: "CDP_ERROR";
75
+ readonly SCRIPT_INVALID: "SCRIPT_INVALID";
76
+ readonly SCRIPT_NOT_FOUND: "SCRIPT_NOT_FOUND";
77
+ readonly SCRIPT_TARGET_NOT_FOUND: "SCRIPT_TARGET_NOT_FOUND";
78
+ readonly SCRIPT_STEP_FAILED: "SCRIPT_STEP_FAILED";
75
79
  };
76
80
  /** One machine-readable failure code. */
77
81
  export type BrowserErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
package/lib/contract.js CHANGED
@@ -20,6 +20,8 @@ export const TOOL_SUFFIXES = [
20
20
  'back',
21
21
  'forward',
22
22
  'wait',
23
+ 'run_script',
24
+ 'save_script',
23
25
  'tabs',
24
26
  'switch_tab',
25
27
  'open_tab',
@@ -47,6 +49,8 @@ export const ALWAYS_ON_SUFFIXES = [
47
49
  'back',
48
50
  'forward',
49
51
  'wait',
52
+ 'run_script',
53
+ 'save_script',
50
54
  'tabs',
51
55
  'switch_tab',
52
56
  'open_tab',
@@ -119,4 +123,8 @@ export const ERROR_CODES = {
119
123
  CDP_DISABLED: 'CDP_DISABLED',
120
124
  CDP_DENIED: 'CDP_DENIED',
121
125
  CDP_ERROR: 'CDP_ERROR',
126
+ SCRIPT_INVALID: 'SCRIPT_INVALID',
127
+ SCRIPT_NOT_FOUND: 'SCRIPT_NOT_FOUND',
128
+ SCRIPT_TARGET_NOT_FOUND: 'SCRIPT_TARGET_NOT_FOUND',
129
+ SCRIPT_STEP_FAILED: 'SCRIPT_STEP_FAILED',
122
130
  };
@@ -86,4 +86,33 @@ export declare function writeGates(allowEvaluate: boolean, allowCdp: boolean, di
86
86
  allowCdp: boolean;
87
87
  diagnostics: boolean;
88
88
  }>;
89
+ /**
90
+ * 改写块里的 proxy 行:有就改、没有就插、空字符串就删。其它行逐字节保留。
91
+ *
92
+ * @param body - 托管块全文(含标记)
93
+ * @param proxy - 已经过 normalizeProxy 校验的地址;'' 表示删掉这一行
94
+ * @returns 改好的块
95
+ */
96
+ export declare function patchBlockProxy(body: string, proxy: string): string;
97
+ /** 读托管块里的 proxy 值;没有就是 ''(= 直连)。 */
98
+ export declare function parseBrowserBlockProxy(text: string): string;
99
+ /**
100
+ * 把 proxy 合并进文件内容:没有托管块就先按模板建一个,再改这一行。
101
+ *
102
+ * @param original - 原文件内容
103
+ * @param proxy - 已校验的地址;'' = 删掉
104
+ * @returns 新文件内容
105
+ */
106
+ export declare function mergeBrowserBlockProxy(original: string, proxy: string): string;
107
+ /**
108
+ * 写 proxy:备份 → 原子替换 → 读回校验;校验失败恢复原文件。
109
+ *
110
+ * @param proxy - 已校验的地址;'' 表示删掉这一行(= 直连)
111
+ * @param env - 环境(测试用 DSH_HOME 覆盖)
112
+ * @returns 写到的文件与现在的值
113
+ */
114
+ export declare function writeProxy(proxy: string, env?: NodeJS.ProcessEnv): Promise<{
115
+ patch: string;
116
+ proxy: string;
117
+ }>;
89
118
  export {};