lasso-mcp 1.20.0 → 1.21.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.
Files changed (76) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/README.en.md +8 -4
  3. package/README.md +8 -4
  4. package/dist/browse/upstream-response.d.ts +7 -0
  5. package/dist/browse/upstream-response.d.ts.map +1 -1
  6. package/dist/browse/upstream-response.js +11 -1
  7. package/dist/browse/upstream-response.js.map +1 -1
  8. package/dist/channels/BrowseChannel.d.ts +15 -0
  9. package/dist/channels/BrowseChannel.d.ts.map +1 -1
  10. package/dist/channels/BrowseChannel.js +166 -28
  11. package/dist/channels/BrowseChannel.js.map +1 -1
  12. package/dist/doctor/doctor.d.ts +20 -1
  13. package/dist/doctor/doctor.d.ts.map +1 -1
  14. package/dist/doctor/doctor.js +63 -6
  15. package/dist/doctor/doctor.js.map +1 -1
  16. package/dist/index.js +38 -16
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher/chrome-hide.d.ts +65 -0
  19. package/dist/launcher/chrome-hide.d.ts.map +1 -1
  20. package/dist/launcher/chrome-hide.js +121 -0
  21. package/dist/launcher/chrome-hide.js.map +1 -1
  22. package/dist/launcher/chrome-hideshow-cli.d.ts +4 -1
  23. package/dist/launcher/chrome-hideshow-cli.d.ts.map +1 -1
  24. package/dist/launcher/chrome-hideshow-cli.js +14 -2
  25. package/dist/launcher/chrome-hideshow-cli.js.map +1 -1
  26. package/dist/launcher/chrome-idle-reaper.d.ts.map +1 -1
  27. package/dist/launcher/chrome-idle-reaper.js +6 -0
  28. package/dist/launcher/chrome-idle-reaper.js.map +1 -1
  29. package/dist/launcher/chrome-ledger.d.ts +63 -1
  30. package/dist/launcher/chrome-ledger.d.ts.map +1 -1
  31. package/dist/launcher/chrome-ledger.js +87 -1
  32. package/dist/launcher/chrome-ledger.js.map +1 -1
  33. package/dist/launcher/chrome-stop.d.ts +27 -2
  34. package/dist/launcher/chrome-stop.d.ts.map +1 -1
  35. package/dist/launcher/chrome-stop.js +34 -8
  36. package/dist/launcher/chrome-stop.js.map +1 -1
  37. package/dist/launcher/desired-hide-enforcer.d.ts +92 -3
  38. package/dist/launcher/desired-hide-enforcer.d.ts.map +1 -1
  39. package/dist/launcher/desired-hide-enforcer.js +147 -15
  40. package/dist/launcher/desired-hide-enforcer.js.map +1 -1
  41. package/dist/launcher/desired-hide-watchdog.d.ts +16 -9
  42. package/dist/launcher/desired-hide-watchdog.d.ts.map +1 -1
  43. package/dist/launcher/desired-hide-watchdog.js +75 -5
  44. package/dist/launcher/desired-hide-watchdog.js.map +1 -1
  45. package/dist/launcher/launch-chrome.d.ts +33 -2
  46. package/dist/launcher/launch-chrome.d.ts.map +1 -1
  47. package/dist/launcher/launch-chrome.js +140 -12
  48. package/dist/launcher/launch-chrome.js.map +1 -1
  49. package/dist/tools/browse.d.ts.map +1 -1
  50. package/dist/tools/browse.js +4 -0
  51. package/dist/tools/browse.js.map +1 -1
  52. package/dist/tools/descriptions.d.ts.map +1 -1
  53. package/dist/tools/descriptions.js +24 -8
  54. package/dist/tools/descriptions.js.map +1 -1
  55. package/dist/types.d.ts +9 -0
  56. package/dist/types.d.ts.map +1 -1
  57. package/doc/bugs/03-2026-09-07-hidden/346/241/243/345/212/253/346/214/201/347/224/250/346/210/267Chrome/346/277/200/346/264/273.md +302 -0
  58. package/doc//345/257/271/346/216/245/345/256/236/346/226/275/350/257/264/346/230/216-/346/270/262/346/237/223/346/241/243x-media-gen-mcp.md +12 -6
  59. package/doc//347/231/273/350/256/260/351/227/256/351/242/230-timing/345/201/207/347/272/242/344/270/216/351/200/200/345/207/272/345/245/221/347/272/246/344/270/216/351/200/232/347/224/250/345/214/226.md +206 -0
  60. package/package.json +1 -1
  61. package/src/browse/upstream-response.ts +11 -1
  62. package/src/channels/BrowseChannel.ts +172 -30
  63. package/src/doctor/doctor.ts +76 -6
  64. package/src/index.ts +38 -16
  65. package/src/invariants/check-invariants.mjs +278 -7
  66. package/src/launcher/chrome-hide.ts +168 -0
  67. package/src/launcher/chrome-hideshow-cli.ts +19 -3
  68. package/src/launcher/chrome-idle-reaper.ts +5 -0
  69. package/src/launcher/chrome-ledger.ts +121 -2
  70. package/src/launcher/chrome-stop.ts +73 -14
  71. package/src/launcher/desired-hide-enforcer.ts +233 -15
  72. package/src/launcher/desired-hide-watchdog.ts +90 -10
  73. package/src/launcher/launch-chrome.ts +173 -13
  74. package/src/tools/browse.ts +4 -0
  75. package/src/tools/descriptions.ts +24 -8
  76. package/src/types.ts +9 -0
package/ARCHITECTURE.md CHANGED
@@ -148,10 +148,10 @@ outcome = worked | didnt | unknown
148
148
  ### 3.6 生命周期与稳定性(v1.9 → v1.13)
149
149
 
150
150
  - **无头浏览器**:空闲 5 分钟自动回收(`LASSO_HEADLESS_IDLE_MS`,v1.9;touchKeepalive 防误杀)
151
- - **launch-chrome**:台账(`launched-chromes.json`)+ `lasso chrome-stop`(只杀 cmdline 验证归属的 pid,防 pid 复用误杀,v1.9);hidden 档默认 + 最后使用后 ~60s 自动关(`LASSO_LAUNCH_MODE` / `LASSO_LAUNCH_IDLE_MS`,v1.10);`admin tab_restore` 恢复用户原 tab(红线:不关用户原有 tab;v1.17.1 修复恒 no-op 缺陷,双 targetId 守卫)。**bug02 修复 2(v1.18.6,doc/bugs/02):CLI 显式拉起默认 `idleMs:0`**——用户/工作流手敲命令要的 Chrome 不被后台 server reaper 60s 静默收割(外部 CDP 直连消费者误杀根因);显式配置(env / config.json `LASSO_LAUNCH_IDLE_MS`)与 argv `--idle-ms` 仍最高优先
151
+ - **launch-chrome**:台账(`launched-chromes.json`)+ `lasso chrome-stop`(只杀 cmdline 验证归属的 pid,防 pid 复用误杀,v1.9);hidden 档默认 + 最后使用后 ~60s 自动关(`LASSO_LAUNCH_MODE` / `LASSO_LAUNCH_IDLE_MS`,v1.10);`admin tab_restore` 恢复用户原 tab(红线:不关用户原有 tab;v1.17.1 修复恒 no-op 缺陷,双 targetId 守卫)。**bug02 修复 2(v1.18.6,doc/bugs/02):CLI 显式拉起默认 `idleMs:0`**——用户/工作流手敲命令要的 Chrome 不被后台 server reaper 60s 静默收割(外部 CDP 直连消费者误杀根因);显式配置(env / config.json `LASSO_LAUNCH_IDLE_MS`)与 argv `--idle-ms` 仍最高优先。**BUG-03 决议 A1/A2/B2(doc/bugs/03):CLI 默认翻 30min(`CLI_LAUNCH_IDLE_DEFAULT_MS` 单一真源)+ 停机三维谓词(modes × ownerPid × userTakenAt 豁免——任何进程退出只许收自己拉起的 Chrome,消费方「连坐死」根治)+ 端口占用归因(自家挂死 Chrome 收尸重拉 `ledger_zombie_collected`;用户资产永不代杀 `never_kill_user_asset`;🔴 对抗复审 r2-F1:**用户拥有记录**(`userTakenAt` 已认领 / visible 登录窗,`isUserOwnedRecord`)同样永不进程序化收尸——唯一出口 = 用户关闭或用户本人显式 chrome-stop,`user_taken_asset` token 如实拒绝)+ `--mode headless` 可选档(零窗口/零 AX 纯抓取形态;🔴 对抗复审 r1 真机证伪原「不占 Dock 槽位」声明——macOS 激活仍被同 bundle id 吸收,无人值守机器专用)**
152
152
  - **hide 粘滞复隐看门狗**(v1.18.3,P27):`chrome-hide` 转后台的窗口被任何来源掀出(含上游页面自己弹的)约 1.5s 内自动压回;`chrome-show` 明示解除;粘滞状态跨重启保留(`desired-hide-state.ts`);`chrome-hide/--pid` 台账缺条目时按进程号直达(只认 lasso 自己的 profile);hide 原语异步化(Chrome 正忙时不拖慢并行操作)
153
153
  - **chrome-touch 外部活动信号**(v1.18.6 bug02 闭环,`chrome-touch.ts`):约定文件 `~/.cache/lasso/chrome-touch-<port>` 的 **mtime 即活动信号**——任何第二消费者(外部 CDP 工具 / shell 工作流)一行 `touch` 即续命,不需要 lasso API、不改 reaper 写路径;reaper 每 tick 顺带 stat(`chromeTouchMtimeSync`)。修的是 R-INT-07 活案例:touch 活动源曾唯一(LoggedInChannel browse 回调),外部 CDP 直连消费者对 reaper 完全不可见、台账 Chrome 60s 被误收割
154
- - **desired-hide-enforcer 独立执守**(v1.18.6 bug02,`desired-hide-enforcer.ts`):粘滞看门狗「只活在 server 进程」而 Chrome 是 detached+unref 的失配(server 崩溃 / 3s race 失败 / 纯 CLI 拉起三种窗口)由**独立执守进程**兜底——chrome-hide 成功 / launch-chrome hidden 记账后 `ensureHideEnforcerRunning()`(pidfile + ps cmdline 标记双复验的 detached 单例);执守体复用 `startDesiredHideWatchdog` 单一调度真源 + 账空 2 tick 自退(不留常驻 node);与 server 内看门狗并发安全(reassert「可见才压回」幂等)
154
+ - **desired-hide-enforcer 独立执守**(v1.18.6 bug02,`desired-hide-enforcer.ts`):粘滞看门狗「只活在 server 进程」而 Chrome 是 detached+unref 的失配(server 崩溃 / 3s race 失败 / 纯 CLI 拉起三种窗口)由**独立执守进程**兜底——chrome-hide 成功 / launch-chrome hidden 记账后 `ensureHideEnforcerRunning()`(pidfile + ps cmdline 标记双复验的 detached 单例);执守体复用 `startDesiredHideWatchdog` 单一调度真源 + 账空 2 tick 自退(不留常驻 node);与 server 内看门狗并发安全(reassert「可见才压回」幂等)。**BUG-03 A1/B1:执守扩双职责**——粘滞复隐 + 日常档(hidden/headless)idle 收割(`startEnforcerIdleReaper`,server 不在时收割宿主;双职责自退闩——两账都空才 exit);**压回决策接入用户激活让位门**(`reassertChromeHiddenGatedAsync`:hidSystemState 年龄 <10s AND 本 Chrome frontmost 双判据在单次 osascript 内判定且先于压回;判据不可得整体跳过零副作用;看门狗确认窗连续 20 tick 才落台账 `userTakenAt`——认领后执守退位/idle 禁收/停机豁免,唯一清除路径 = 显式 chrome-hide 重武装;chrome-show 同标 userTakenAt)。INV-85/86 守
155
155
  - **stdin-EOF 收尾**(v1.12):CC 异常退出 → 父进程死 → stdin EOF → 复用幂等 shutdown;受管子进程不再孤儿到 zombie reaper 1h 阈值(上游 SDK #2002 的进程侧缓解)
156
156
  - **停机链全路径有界**(v1.13):Steel 会话释放双层 3s 上界(停机链 race 3s + fetch `AbortSignal.timeout(3s)`)——自托管 Steel 停摆/endpoint 悬挂(实测可挂 ~301s)不再拖死退出
157
157
  - **`LASSO_PROXY` 出口代理**(v1.11):headless 经 `--proxy-server`、Steel 经 session `proxyUrl`;**`browse_logged_in` 永不读取**(用户真实 Chrome 出口原样,铁律);doctor `proxy_config` 回显
package/README.en.md CHANGED
@@ -206,7 +206,7 @@ Goes to the Internet Archive (Wayback Machine) to find the last archived copy of
206
206
 
207
207
  ## Install
208
208
 
209
- **Current version v1.20.0** (changelog in the collapsed block at the end of this section).
209
+ **Current version v1.21.0** (changelog in the collapsed block at the end of this section).
210
210
 
211
211
  Prerequisites: Node.js ≥ 20 + Claude Code (or any MCP-capable client).
212
212
 
@@ -223,6 +223,7 @@ Restart Claude Code → `/mcp` → `lasso ✓ Connected`. **That's the one line
223
223
  <details>
224
224
  <summary>📋 Changelog (v1.8 → v1.18.6 — click to see what each version changed)</summary>
225
225
 
226
+ - **v1.21.0**: browser-sovereignty & lifecycle root-fix batch (BUG-03, driven by a live-machine incident) — ① 🔴 **behavior change**: `launch-chrome` CLI default idle reaping changes from "never reap" to **auto-reap after 30 minutes** (the old never-reap behavior is restored explicitly with `--idle-ms 0`); ② **user-activation yielding gate**: when you un-hide lasso's hidden Chrome via Dock/Spotlight, lasso recognizes it as a **human at the machine** via a dual predicate (HID idle + frontmost window) — lets it through, then steps aside after a 30-second confirmation window (page-spawned popups are still pressed back; the v1.18.3 silence contract is unchanged); ③ **no collateral kill on shutdown**: fixes a P0 — any lasso server process exiting used to sweep-kill **every hidden Chrome** in the global ledger (violating the `--idle-ms 0` keep-alive promise); collection is now filtered by ownership in three dimensions, so CLI-launched instances survive server exits and are still reaped on idle; ④ **zombie-port self-healing**: when 9222 is occupied by lasso's own hung instance, it is no longer misreported as "a non-CDP process" — the ledger is consulted, the zombie collected, and a fresh instance relaunched; doctor classifies port occupiers three ways (own zombie / stale / user asset) and **never suggests killing a user asset** (forged ledger entries and impersonating pids are also refused; new invariants INV-85/86/87); ⑤ consumer-field fixes (cc-control feedback): `browse` screenshot upstream-contract adaptation (filePath direct-write for large images + Access-denied fallback retry, closing the in-session screenshot gap), `evaluate` always-returning-undefined fixed (dual-form function-expression passthrough), session rotation classified as retryable `session_rotated`; ⑥ optional `launch-chrome --mode headless` tier (for unattended use — honest caveat: headless still shares the Chrome bundle slot). Three adversarial rounds closed: 28/28 mutations killed + 3 CRITICALs fixed and re-verified PASS; tests 2595→2676 / invariants 84→87.
226
227
  - **v1.20.0**: render-tier parallel-safety batch (stop/doctor port scoping) — ① 🔴 **behavior change**: `render-chrome --stop` previously ignored `LASSO_RENDER_PORT` and killed **every render tier on the machine** (the #1 entry point for parallel acceptance agents killing each other's instances); now, **an explicitly set `LASSO_RENDER_PORT` stops only that port** (symmetric with `--ensure`/`--status`), unset = collect-all unchanged, an invalid value exits 1 as a usage error. Old scripts that set the port yet expect a full sweep should switch to `chrome-stop --modes render`; the `--stop` output shape is unchanged (idempotent `{"stopped":[...]}`). ② `render-chrome doctor` orphan judgment is scoped the same way with double exemption: an explicit port scans only that port, and other namespaces' in-use instances are no longer misjudged as orphans (out-of-scope port + touch heartbeat within 10 minutes, both exempt — `--clean` no longer kills in-use instances). ③ the **multi-agent parallel acceptance** three-env recipe (`LASSO_RENDER_PORT` + `LASSO_LAUNCHED_CHROMES_PATH` + `LASSO_RENDER_GUARDIAN_PID_PATH`) verified on a live machine: two terminals tearing down concurrently, mutually invisible, zero cross-kills. Consumers like media-gen-mcp only call `--ensure` and are unaffected (verified at source level).
227
228
  - **v1.19.0**: deterministic render tier + performance/accuracy batch — ① new `lasso-mcp render-chrome`: a **deterministic rendering browser** for other AI tools (e.g. media-gen-mcp) — one `--ensure` line launches a headless Chrome with a frozen flag snapshot (the browser survives consumer kills; auto-reclaimed after 10 min idle with profile cleanup; see README render section); ② perf: handshake budget on engine cold start (fast-fail instead of 60s hangs, tunable via `LASSO_MCP_HANDSHAKE_TIMEOUT_MS`), first-install npx tax removed, 2FA probe made idempotent, fast-path for truncated large evaluations; ③ accuracy: SERP layout-change detection chain live (selector rot auto-detected with baseline recording). Tool interfaces unchanged (purely additive subcommand and flags).
228
229
  - **v1.18.8**: doctor & CLI polish — ① `doctor` no longer reports "unhealthy" due to **ad-hoc signing** (previously a false `ready:false`: ad-hoc is Apple's free legitimate scheme, fully functional — the only cost is re-granting system permissions after rebuilds; a proper Developer ID signature still passes); ② `lasso launch-chrome --help` fixed (previously it actually launched a Chrome; now prints usage); ③ architecture doc fully aligned + 20 legacy cleanups (tests up to 2471 / invariants 84).
@@ -300,7 +301,7 @@ lasso launch-chrome
300
301
  The first time, log in to your accounts in that window (handle 2FA yourself) — **the login state is reused from then on**. Afterwards, just say "open my logged-in Jira" to Claude.
301
302
 
302
303
  - Works **silently with zero window** by default — never steals focus, always muted; add `--mode visible` to watch it work
303
- - CLI-launched Chrome **no longer auto-closes by default** (v1.18.5a Chrome you explicitly asked for is never silently reaped by a background timer after 60 s); for "close when done" configure `LASSO_LAUNCH_IDLE_MS`; `lasso chrome-stop` closes it manually at any time
304
+ - CLI-launched Chrome **auto-collects after 30 min of no activity by default** (BUG-03 A1 "stays alive while in use, auto-collected when idle"; external tools keep it alive with one `touch ~/.cache/lasso/chrome-touch-<port>`; long sessions opt out with `--idle-ms 0`); `lasso chrome-stop` closes it manually at any time
304
305
  - Tabs it opens in your Chrome are restored to the original list when you say `admin {action:"tab_restore", reason:"done"}` after a task (the same happens automatically on server exit)
305
306
 
306
307
  > 🔴 **Red line**: 2FA / verification codes / CAPTCHA — Lasso doesn't solve these for you; pass them manually once in the window.
@@ -310,13 +311,16 @@ The first time, log in to your accounts in that window (handle 2FA yourself) —
310
311
 
311
312
  - Since v1.8, a dedicated Lasso profile is used by default (Chrome 136+ forbids opening a debug port on the default profile — the old way would exit instantly); to reuse an existing profile, use `lasso launch-chrome --profile <dir>`.
312
313
  - After launch, the debug port is probed automatically; if Chrome didn't come up or the port is taken, it fails loudly instead of faking success.
313
- - Auto-close threshold: `LASSO_LAUNCH_IDLE_MS` (default 60000; `300000` reverts to 5 minutes; `0` disables). To let a single long task through: `--idle-ms 3600000`. Since v1.18.5 an **explicit CLI launch defaults to `--idle-ms 0`** (outside the reaper's jurisdiction; explicit config via env / config.json still wins).
314
- - **Keep-alive for external CDP consumers** (v1.18.5): when other tools (chrome-devtools-mcp, automation scripts, …) connect straight to this Chrome's debug port, a single `touch ~/.cache/lasso/chrome-touch-<port>` counts as "just used" Lasso's idle reaper will not kill it. No resident Chrome is needed (hidden-tier cold start reaches a usable CDP in ~1.5 s measured); for a whole working session, launch with `--idle-ms 1800000` (auto-collects at the half-hour mark).
314
+ - Auto-close threshold: `LASSO_LAUNCH_IDLE_MS` (default 60000; `300000` reverts to 5 minutes; `0` disables). To let a single long task through: `--idle-ms 3600000`. Since BUG-03 an **explicit CLI launch defaults to `--idle-ms 1800000` (30 min)** — a server exit never guilt-by-associates CLI-launched Chromes (owner filtering), and the enforcer process (alive even without a server) collects on expiry; explicit env / config.json still wins.
315
+ - **Keep-alive for external CDP consumers** (v1.18.5; wording revised in BUG-03): when other tools (chrome-devtools-mcp, automation scripts, …) connect straight to this Chrome's debug port, a single `touch ~/.cache/lasso/chrome-touch-<port>` counts as "just used" (the promise is now **stays alive while in use** — only silent expiry collects); no resident Chrome is needed (hidden-tier cold start reaches a usable CDP in ~1.5 s measured); for a whole working session launch with `--idle-ms 0` (explicit opt-out of auto-collect) or touch periodically.
315
316
  - The headless browser auto-recycles after 5 minutes idle (`LASSO_HEADLESS_IDLE_MS` to tune or disable).
316
317
  - Honest boundary: running `lasso launch-chrome` standalone (outside the server) has no idle auto-close — the exit is `chrome-stop`; `browse_logged_in` connecting to **your own visible Chrome** is "low-disturbance, not zero-disturbance" (a macOS platform-level limitation — occasional operations may steal focus once) — for pure silence use the hidden tier or `browse_headless`; `desktop` simulates a real human's keyboard and mouse, so it occupies the physical keyboard/mouse by design — there is no silent form.
317
318
  - chrome-stop only closes the Chromes Lasso itself launched, with verified ownership — it never touches your manually-opened browsers.
318
319
  - `chrome-hide` / `chrome-show` likewise only touch ledger-registered Chromes (pid-targeted, never your manually-opened browsers); hide only hides the window — process / login state / CDP all stay. If the ledger has no entry, target by process id with `--pid N` (see above; anything that isn't Lasso's own profile is refused).
319
320
  - Hide is **sticky** (v1.18.3; full lifecycle in v1.18.5): a hidden window un-hidden by any source (an upstream page popping itself, a tab opened by an external tool, a system focus switch) is pressed back into the background within ~1.5 s; use `chrome-show` when you want to look at it (explicit release, no more pressing back). The sticky state survives restarts; since v1.18.5 the hidden tier is **protected from birth** (the launch itself writes the sticky ledger), and a standalone enforcer process backs it — **even when the Claude session / server is gone, an un-hidden Chrome gets pressed back** (the enforcer is a short-lived process that self-exits on an empty ledger — no resident overhead).
321
+ - **When YOU open it by hand, the enforcer yields** (BUG-03 B1): the press-back decision has a user-activation yield gate — a Dock click / your manual activation (detected as this Chrome being frontmost + recent HID input, confirmed over ~30 s) gets claimed: no more pressing back, no idle collection; the only close exits are you closing it or an explicit `chrome-stop`. Programmatic pop-ups still get pressed back as before. Re-arm anytime with `lasso chrome-hide`.
322
+ - **Port taken by your own Chrome? Lasso never touches it** (BUG-03 C): on `port_in_use_non_cdp`, a Lasso-owned dead instance that is **not user-owned** self-heals automatically (`ledger_zombie_collected`); one the user has taken over (`user_taken_asset` claimed / visible login window) is **never auto-cleared** — its only exits are the user closing it or running `chrome-stop` themselves; if the occupier is your manually-opened Chrome or another program, the error says so honestly — "user asset, will not kill, decide yourself or pick another port" — Lasso provides no kill escape hatch for non-ledger assets.
323
+ - For pure scraping without login state on an **unattended** machine, `--mode headless` (BUG-03 B2) gives a zero-window, zero-AX footprint. Real-machine correction (BUG-03 adversarial review): on macOS it does **not** free the Dock slot — while it runs, clicking the Chrome icon produces no visible response (the activation is absorbed by the headless instance, which has no window to show); on machines a human may use, prefer the default hidden mode (its yield gate lets you take the window over). The tradeoffs: no window, `chrome-show` ineffective — use hidden/visible for login flows.
320
324
 
321
325
  </details>
322
326
 
package/README.md CHANGED
@@ -215,7 +215,7 @@ macOS 上能控 Finder / Mail / Safari / Notes / 系统设置等任何原生 app
215
215
 
216
216
  ## 安装
217
217
 
218
- **当前版本 v1.20.0**(更新日志见本节末尾折叠块)。
218
+ **当前版本 v1.21.0**(更新日志见本节末尾折叠块)。
219
219
 
220
220
  前提:Node.js ≥ 20 + Claude Code(或任何支持 MCP 的客户端)。
221
221
 
@@ -232,6 +232,7 @@ claude mcp add lasso -- npx -y lasso-mcp
232
232
  <details>
233
233
  <summary>📋 更新日志(v1.8 → v1.18.6,点开看每版改了什么)</summary>
234
234
 
235
+ - **v1.21.0**:浏览器主权与生命周期根治批(BUG-03,真机事故驱动)——① 🔴 **行为变更**:`launch-chrome` CLI 默认空闲回收从「永不回收」改为 **30 分钟自动回收**(旧永不回收行为用 `--idle-ms 0` 显式恢复);② **用户激活让位门**:你点 Dock/Spotlight 掀出 lasso 隐藏的 Chrome 时,lasso 以「HID 空闲 + 前置窗口」双判据识别这是**人在操作**——放行、30 秒确认窗后退位(页面自弹的窗口照常压回,v1.18.3 静默契约零回归);③ **停机不连坐**:修复一个 P0——此前任意 lasso server 进程退出会按全局台账**扫杀全部隐藏 Chrome**(违背 `--idle-ms 0` 保活承诺);现在按归属三维过滤,CLI 拉起的实例在 server 退出后照常存活、照常被回收;④ **僵尸占位自愈**:9222 被自家挂死实例占用时不再误报「非 CDP 进程」,自动按台账归因收尸重拉;doctor 端口占用三分类(自家僵尸/陈留/用户资产),**永不建议代杀用户资产**(台账伪造 + pid 冒名也拒杀,新不变量 INV-85/86/87);⑤ 消费方实战修复(cc-control 反馈):`browse` 截图上游契约适配(大图 filePath 直写 + Access-denied 降级重试,会话内截图通路补齐)、`evaluate` 恒返 undefined 根治(函数表达式双形态透传)、会话轮换归类为可重试的 `session_rotated`;⑥ `launch-chrome --mode headless` 可选档(无人值守专用——诚实声明:headless 也共享 Chrome bundle 槽位)。对抗三轮闭环:28 变异全杀 + 3 个 CRITICAL 当场修复复核 PASS;测试 2595→2676 / 不变量 84→87。
235
236
  - **v1.20.0**:渲染档并行安全批(stop/doctor 端口作用域化)——① 🔴 **行为变更**:`render-chrome --stop` 此前无视 `LASSO_RENDER_PORT`、会收掉**全机**渲染档(同机多 agent 并行验收时互杀对方实例的第一入口);现在**显式设了 `LASSO_RENDER_PORT` 只收该 port**(与 `--ensure`/`--status` 对称),未设=维持全收不变,非法值 exit 1 报用法错。设了 port 却想全收的旧脚本请改用 `chrome-stop --modes render`;`--stop` 输出形状不变(幂等 `{"stopped":[...]}`)。② `render-chrome doctor` 孤儿判定同样作用域化 + 双豁免:显式 port 只判该 port,他人命名空间实例不再被误判孤儿(范围外 port + touch 心跳 10 分钟内新鲜双重豁免,`--clean` 不再误杀在用实例)。③ 配套**同机多 agent 并行验收**三 env 隔离配方(`LASSO_RENDER_PORT` + `LASSO_LAUNCHED_CHROMES_PATH` + `LASSO_RENDER_GUARDIAN_PID_PATH`)真机验证:双终端并发各自清场,并行互不可见零互杀。media-gen-mcp 等消费方只调 `--ensure` 不受影响(已源码级实证)。
236
237
  - **v1.19.0**:确定性渲染档 + 性能/准确率批——① 新增 `lasso-mcp render-chrome`:给其它 AI 工具(如 media-gen-mcp)当**确定性渲染浏览器**——`--ensure` 一行拉起带冻结旗标快照的 headless Chrome(消费方进程被强杀浏览器照常存活,空闲 10 分钟自动回收并清理 profile),详见 README 渲染档节;② 性能:浏览器引擎冷启动握手加预算(卡死快速失败不再挂 60 秒,`LASSO_MCP_HANDSHAKE_TIMEOUT_MS` 可调)、首装 npx 下载税消除、2FA 探测幂等化、大求值截断快路;③ 准确率:搜索页改版检测链上线(选择器失效自动发现并记录基线)。工具接口零变化(纯新增子命令与旗标)。
237
238
  - **v1.18.8**:doctor 与 CLI 打磨——① `doctor` 不再因 **ad-hoc 签名**报「不健康」(此前 `ready:false` 误报:ad-hoc 是官方免费合法方案,功能正常,唯一代价是重编译后需重新系统授权;Developer ID 正签仍 pass)② `lasso launch-chrome --help` 修复(此前会误启动一个 Chrome,现在正确打印用法说明)③ 架构文档全量对齐 + 20 项历史遗留清理(测试增至 2471 / 不变量 84)。
@@ -320,7 +321,7 @@ lasso launch-chrome
320
321
  以后对 Claude 说「打开我已登录的 Jira」就行。想再看窗口随时 `lasso chrome-show`(可逆,登录态不动)。
321
322
 
322
323
  - 默认**零窗口静默**干活、不抢焦点、永远静音;想看着它干加 `--mode visible`
323
- - 命令行拉起的 Chrome **默认不再自动关**(v1.18.5——手敲命令要的 Chrome 不被后台 60s 静默回收);想要「用完即关」在 config `LASSO_LAUNCH_IDLE_MS`,手动关随时 `lasso chrome-stop`
324
+ - 命令行拉起的 Chrome **默认 30 分钟无活动自动收**(BUG-03 A1——「有活动就活,无消费者到期自动收」;外部工具 `touch ~/.cache/lasso/chrome-touch-<端口>` 一行续命;长会话显式 `--idle-ms 0` 永不自动收),手动关随时 `lasso chrome-stop`
324
325
  - 它在你 Chrome 里开的 tab,任务后说 `admin {action:"tab_restore", reason:"完成"}` 恢复原列表(server 退出也会自动做)
325
326
 
326
327
  > 🔴 **红线**:2FA / 验证码 / CAPTCHA——Lasso 不替你解,你在窗口里手动过一次。
@@ -330,14 +331,17 @@ lasso launch-chrome
330
331
 
331
332
  - v1.8 起默认用 Lasso 独立 profile(Chrome 136+ 禁止对默认 profile 开调试端口,老办法会秒退);复用已有 profile 用 `lasso launch-chrome --profile <目录>`。
332
333
  - 启动后自动探活调试端口,Chrome 没起来 / 端口被占会明确报错,不假报成功。
333
- - 自动关阈值:`LASSO_LAUNCH_IDLE_MS`(默认 60000;`300000` 回退 5 分钟;`0` 禁用)。单次长任务放行:`--idle-ms 3600000`。v1.18.5 起**命令行显式拉起默认 `--idle-ms 0`**(不进 reaper 管辖;显式配置 env / config.json 仍最高优先)。
334
- - **外部 CDP 消费者续命**(v1.18.5):其他工具(chrome-devtools-mcp、自动化脚本等)直连这条 Chrome 的调试端口时,一行 `touch ~/.cache/lasso/chrome-touch-<端口>` 即视为「刚用过」,lasso idle 回收不会误杀它;不需要常驻(hidden 档冷启动实测 1.5s 内 CDP 可用),一整段会话要用就拉起时配 `--idle-ms 1800000`(半小时到点自动收)。
334
+ - 自动关阈值:`LASSO_LAUNCH_IDLE_MS`(默认 60000;`300000` 回退 5 分钟;`0` 禁用)。单次长任务放行:`--idle-ms 3600000`。BUG-03 轮起**命令行显式拉起默认 `--idle-ms 1800000`(30 分钟)**——server 退出不再连坐任何 CLI 拉起的 Chrome(归属过滤),执守进程(server 不在也活着)负责到期收割;显式配置 env / config.json 仍最高优先。
335
+ - **外部 CDP 消费者续命**(v1.18.5,BUG-03 口径修订):其他工具(chrome-devtools-mcp、自动化脚本等)直连这条 Chrome 的调试端口时,一行 `touch ~/.cache/lasso/chrome-touch-<端口>` 即视为「刚用过」(承诺口径 = **在用就一直活**,无信号才到期收);不需要常驻(hidden 档冷启动实测 1.5s 内 CDP 可用),一整段会话要用就拉起时配 `--idle-ms 0`(显式退出自动收)或定期 touch。
335
336
  - 登录后自动收窗:`LASSO_AUTO_HIDE_AFTER_LOGIN`(默认 false;开启后只在「见过登录页 → 登录页消失 → 等 10 秒 → Claude 没在用」四关全过时才收,收错方向保守——拿不准就不收)。等待时长 `LASSO_AUTO_HIDE_AFTER_LOGIN_DELAY_MS`(默认 10000)。只在 server 会话运行期间生效(CLI 单独 `launch-chrome` 没有调度器,仍走手动 `chrome-hide`)。
336
337
  - 无头浏览器空闲 5 分钟自动回收(`LASSO_HEADLESS_IDLE_MS` 可调/禁用)。
337
338
  - 诚实边界:单独跑 `lasso launch-chrome`(不经 server)没有 idle 自动关,出口是 `chrome-stop`;它起的 Chrome 在 Dock / 任务栏会多一个图标(浏览器有头进程的注册行为,lasso 控制不了),要零图标用 `browse_headless`;`browse_logged_in` 连**你自己开的可见 Chrome** 时会在你的 Chrome 里临时开一个后台 tab 干活(不抢焦点不发声,结束自动关,v1.17.2 起你的 tab 一律不被改写)——但你的 Chrome 本身不静音(lasso 不改写你的浏览器参数),浏览到自动播放页面会真出声;`desktop` 模拟真人键鼠,设计上就占用物理键鼠,没有静默形态。
338
339
  - chrome-stop 只关 Lasso 自己起的、验证过归属的 Chrome,不会误伤你手动开的浏览器。
339
340
  - `chrome-hide` / `chrome-show` 同样只动台账在案的 Chrome(按 pid 定向,永不碰你手动开的浏览器);hide 只隐藏窗口,进程/登录态/CDP 全保留。台账缺条目时按 `--pid N` 直达(见上文;归属不满足会明确拒绝)。
340
341
  - hide 是**粘滞**的(v1.18.3,v1.18.5 补全生命周期):已 hide 的窗口无论被什么来源掀出(上游页面自己弹的、外部工具开的 tab、系统焦点切换),约 1.5 秒内自动压回后台;想看窗口用 `chrome-show`(明示解除,不再压回)。粘滞状态跨重启保留;v1.18.5 起 hidden 档**出生即受保护**(拉起落账即写粘滞),且由独立执守进程兜底——**即使 Claude 会话/server 不在,隐藏的 Chrome 被掀出也会被压回**(执守是账空自退的短命进程,不留常驻开销)。
342
+ - **你亲手点开它时,执守会让位**(BUG-03 B1):压回决策带「用户激活让位门」——Dock 点击 / 你手动点开的激活(判定 = 本 Chrome 处于前台 + 键鼠刚有输入,连续约 30 秒确认)会被认领:不再压回、不再自动收,关它只剩你自己关或显式 `chrome-stop`。程序掀出(页面自己弹的)照常压回。误认领可用 `lasso chrome-hide` 重新武装。
343
+ - **端口被你自己的 Chrome 占了?lasso 永不动它**(BUG-03 C):报 `port_in_use_non_cdp` 时,自家挂死且**未被用户使用**的实例会自动清掉重拉(`ledger_zombie_collected`);已被用户拿去用的实例(已认领 `user_taken_asset` / visible 登录窗)**绝不自动清理**——唯一出口是你自己关掉或你本人跑 `chrome-stop`;是你手开的 Chrome / 其他程序占用则明确说「用户资产,不会 kill,请自行裁决或换口」——lasso 不提供任何针对非自家资产的 kill 出口。
344
+ - 纯抓取不需要登录态时可用 `--mode headless`(BUG-03 B2):无头实例零窗口/零打扰,适合**无人值守**机器的纯抓取。🔴 真机复核订正(BUG-03 对抗复审):macOS 上它**并不**让出 Dock 槽位——它活着时你点 Chrome 图标不会有任何反应(激活被无头实例吸收、没有窗口可显示);有人会用的机器请用默认 hidden(有让位门,你点开它会让位)。代价:没有窗口、`chrome-show` 无效,登录流用 hidden/visible。
341
345
 
342
346
  </details>
343
347
 
@@ -28,6 +28,13 @@ export declare function firstText(r: UpstreamContentResult | undefined): string
28
28
  /**
29
29
  * 提取 evaluate_script 响应里 ``` 围栏内的文本(去掉 ```json 语言标)。
30
30
  * 无围栏(上游形状漂移)返回 undefined。
31
+ *
32
+ * E④(BUG-03 决议 E④,doc/bugs/03 §4 E):贪婪围栏——非贪婪 `[\s\S]*?` 会在
33
+ * **返回值内部含 ``` 的字符串**(如页面源码片段)处提前截断(实测形态:脚本
34
+ * 返回含三反引号的文本 → 围栏在值内部提前闭合 → parseEvalResult 拿到半截)。
35
+ * 上游契约是单围栏形态("# evaluate_script response\n...\n```json\n<值>\n```"),
36
+ * 惰性组 + 负向前瞻 `(?![\s\S]*```)` 把闭合锚定到**最后一个**围栏——值内部的
37
+ * ``` 不再截断,且不把闭合围栏前的换行并入值尾。
31
38
  */
32
39
  export declare function evalFence(r: UpstreamContentResult | undefined): string | undefined;
33
40
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"upstream-response.d.ts","sourceRoot":"","sources":["../../src/browse/upstream-response.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,MAAM,SAAS,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AACxD,MAAM,MAAM,UAAU,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAC7E,MAAM,MAAM,qBAAqB,GAAG;IAClC,OAAO,CAAC,EAAE,KAAK,CAAC,SAAS,GAAG,UAAU,CAAC,CAAC;IACxC,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,0DAA0D;AAC1D,wBAAgB,SAAS,CACvB,CAAC,EAAE,qBAAqB,GAAG,SAAS,GACnC,MAAM,GAAG,SAAS,CAMpB;AAED;;;GAGG;AACH,wBAAgB,SAAS,CACvB,CAAC,EAAE,qBAAqB,GAAG,SAAS,GACnC,MAAM,GAAG,SAAS,CAKpB;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,CAAC,EAAE,qBAAqB,GAAG,SAAS,GACnC,OAAO,CAgBT;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,CAAC,EAAE,qBAAqB,GAAG,SAAS,GACnC,MAAM,GAAG,SAAS,CAKpB;AAED,kEAAkE;AAClE,wBAAgB,UAAU,CACxB,CAAC,EAAE,qBAAqB,GAAG,SAAS,GACnC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAQhD"}
1
+ {"version":3,"file":"upstream-response.d.ts","sourceRoot":"","sources":["../../src/browse/upstream-response.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,MAAM,SAAS,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AACxD,MAAM,MAAM,UAAU,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAC7E,MAAM,MAAM,qBAAqB,GAAG;IAClC,OAAO,CAAC,EAAE,KAAK,CAAC,SAAS,GAAG,UAAU,CAAC,CAAC;IACxC,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,0DAA0D;AAC1D,wBAAgB,SAAS,CACvB,CAAC,EAAE,qBAAqB,GAAG,SAAS,GACnC,MAAM,GAAG,SAAS,CAMpB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CACvB,CAAC,EAAE,qBAAqB,GAAG,SAAS,GACnC,MAAM,GAAG,SAAS,CAQpB;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,CAAC,EAAE,qBAAqB,GAAG,SAAS,GACnC,OAAO,CAgBT;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,CAAC,EAAE,qBAAqB,GAAG,SAAS,GACnC,MAAM,GAAG,SAAS,CAKpB;AAED,kEAAkE;AAClE,wBAAgB,UAAU,CACxB,CAAC,EAAE,qBAAqB,GAAG,SAAS,GACnC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAQhD"}
@@ -23,12 +23,22 @@ export function firstText(r) {
23
23
  /**
24
24
  * 提取 evaluate_script 响应里 ``` 围栏内的文本(去掉 ```json 语言标)。
25
25
  * 无围栏(上游形状漂移)返回 undefined。
26
+ *
27
+ * E④(BUG-03 决议 E④,doc/bugs/03 §4 E):贪婪围栏——非贪婪 `[\s\S]*?` 会在
28
+ * **返回值内部含 ``` 的字符串**(如页面源码片段)处提前截断(实测形态:脚本
29
+ * 返回含三反引号的文本 → 围栏在值内部提前闭合 → parseEvalResult 拿到半截)。
30
+ * 上游契约是单围栏形态("# evaluate_script response\n...\n```json\n<值>\n```"),
31
+ * 惰性组 + 负向前瞻 `(?![\s\S]*```)` 把闭合锚定到**最后一个**围栏——值内部的
32
+ * ``` 不再截断,且不把闭合围栏前的换行并入值尾。
26
33
  */
27
34
  export function evalFence(r) {
28
35
  const text = firstText(r);
29
36
  if (!text)
30
37
  return undefined;
31
- const m = text.match(/```(?:json)?\s*\n([\s\S]*?)\n?```/);
38
+ // 贪婪语义经「惰性组 + 负向前瞻锚定最后一个围栏」实现:惰性组照常最短匹配,
39
+ // 但闭合围栏后不允许再出现 ``` ——若值内部还有 ```,引擎回溯到真正的闭合围栏。
40
+ // (纯贪婪 `[\s\S]*` 也能匹配到最后围栏,但会把闭合围栏前的换行并入值尾。)
41
+ const m = text.match(/```(?:json)?\s*\n([\s\S]*?)\n?```(?![\s\S]*```)/);
32
42
  return m?.[1];
33
43
  }
34
44
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"upstream-response.js","sourceRoot":"","sources":["../../src/browse/upstream-response.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AASH,0DAA0D;AAC1D,MAAM,UAAU,SAAS,CACvB,CAAoC;IAEpC,IAAI,CAAC,CAAC,EAAE,OAAO;QAAE,OAAO,SAAS,CAAC;IAClC,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC;QAC1B,IAAI,CAAC,CAAC,IAAI,KAAK,MAAM,IAAI,CAAC,CAAC,IAAI;YAAE,OAAO,CAAC,CAAC,IAAI,CAAC;IACjD,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,SAAS,CACvB,CAAoC;IAEpC,MAAM,IAAI,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;IAC1B,IAAI,CAAC,IAAI;QAAE,OAAO,SAAS,CAAC;IAC5B,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,mCAAmC,CAAC,CAAC;IAC1D,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AAChB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAC7B,CAAoC;IAEpC,MAAM,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;IAC3B,IAAI,KAAK,IAAI,IAAI;QAAE,OAAO,SAAS,CAAC;IACpC,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAC/B,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC7B,IAAI,CAAC;gBACH,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YAC1B,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,IAAI,CAAC,CAAC,gCAAgC;YAC/C,CAAC;QACH,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAC3B,CAAoC;IAEpC,MAAM,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC,CAAC;IAC7B,IAAI,CAAC,IAAI,IAAI;QAAE,OAAO,SAAS,CAAC;IAChC,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,CAAC,CAAC;IACpC,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC;AACnB,CAAC;AAED,kEAAkE;AAClE,MAAM,UAAU,UAAU,CACxB,CAAoC;IAEpC,IAAI,CAAC,CAAC,EAAE,OAAO;QAAE,OAAO,SAAS,CAAC;IAClC,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC;QAC1B,IAAI,CAAC,CAAC,IAAI,KAAK,OAAO,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC;YACjC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,QAAQ,IAAI,WAAW,EAAE,CAAC;QAC/D,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC"}
1
+ {"version":3,"file":"upstream-response.js","sourceRoot":"","sources":["../../src/browse/upstream-response.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AASH,0DAA0D;AAC1D,MAAM,UAAU,SAAS,CACvB,CAAoC;IAEpC,IAAI,CAAC,CAAC,EAAE,OAAO;QAAE,OAAO,SAAS,CAAC;IAClC,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC;QAC1B,IAAI,CAAC,CAAC,IAAI,KAAK,MAAM,IAAI,CAAC,CAAC,IAAI;YAAE,OAAO,CAAC,CAAC,IAAI,CAAC;IACjD,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,SAAS,CACvB,CAAoC;IAEpC,MAAM,IAAI,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;IAC1B,IAAI,CAAC,IAAI;QAAE,OAAO,SAAS,CAAC;IAC5B,yCAAyC;IACzC,8CAA8C;IAC9C,6CAA6C;IAC7C,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,iDAAiD,CAAC,CAAC;IACxE,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AAChB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAC7B,CAAoC;IAEpC,MAAM,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;IAC3B,IAAI,KAAK,IAAI,IAAI;QAAE,OAAO,SAAS,CAAC;IACpC,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAC/B,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC7B,IAAI,CAAC;gBACH,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YAC1B,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,IAAI,CAAC,CAAC,gCAAgC;YAC/C,CAAC;QACH,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAC3B,CAAoC;IAEpC,MAAM,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC,CAAC;IAC7B,IAAI,CAAC,IAAI,IAAI;QAAE,OAAO,SAAS,CAAC;IAChC,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,CAAC,CAAC;IACpC,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC;AACnB,CAAC;AAED,kEAAkE;AAClE,MAAM,UAAU,UAAU,CACxB,CAAoC;IAEpC,IAAI,CAAC,CAAC,EAAE,OAAO;QAAE,OAAO,SAAS,CAAC;IAClC,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC;QAC1B,IAAI,CAAC,CAAC,IAAI,KAAK,OAAO,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC;YACjC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,QAAQ,IAAI,WAAW,EAAE,CAAC;QAC/D,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC"}
@@ -187,6 +187,21 @@ export declare abstract class BrowseChannel extends UiChannel {
187
187
  */
188
188
  private persistState;
189
189
  }
190
+ /**
191
+ * E④(BUG-03 决议 E④):evaluate 的 js 入参 → 上游 function 参数归一(导出供测试)。
192
+ *
193
+ * 双形态兼容:
194
+ * - **函数表达式**(起手 `(` / `function` / `async`,或单标识符箭头 `x => x`)
195
+ * → 原样透传——上游 evaluate_script 契约本就吃函数表达式并自调用;旧 wrapper
196
+ * 会把它包成「函数体内的函数表达式语句」(求值不 return → 恒 undefined 静默
197
+ * 错值——消费方④实战毒点)。
198
+ * - **语句体**(`return ...` / 声明 / 多语句 / 裸表达式)→ 维持包裹
199
+ * `() => { ... }`(W1-DEF-1b:直接透传 `return` 会被当函数表达式语法错,
200
+ * wave2 smoke 实证 "Unexpected token 'return'")。
201
+ * 判定规则刻意保守(起手 token 白名单):误判方向 = 语句体被透传(上游报语法
202
+ * 错,响亮可修),优于函数表达式被包裹(静默 undefined,毒中之毒)。
203
+ */
204
+ export declare function evaluateFunctionArg(js: string): string;
190
205
  /**
191
206
  * v1.17(doc/governance/06 verify ⑤,真机实证):refs 附录感知截断——长页正文超
192
207
  * PREVIEW_MAX_CHARS 时朴素 truncatePreview 会把**缀在末尾**的
@@ -1 +1 @@
1
- {"version":3,"file":"BrowseChannel.d.ts","sourceRoot":"","sources":["../../src/channels/BrowseChannel.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"BrowseChannel.d.ts","sourceRoot":"","sources":["../../src/channels/BrowseChannel.ts"],"names":[],"mappings":"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"}
@@ -34,7 +34,8 @@
34
34
  * mcp-chrome chrome_computer action-enum 折叠思想。
35
35
  */
36
36
  import { randomUUID } from "node:crypto";
37
- import { writeFile, stat } from "node:fs/promises";
37
+ import { writeFile, stat, mkdir, open, readFile } from "node:fs/promises";
38
+ import * as pathMod from "node:path";
38
39
  import { UiChannel } from "./UiChannel.js";
39
40
  import { writeState, withOperation } from "../util/state-store.js";
40
41
  import { logger } from "../util/logger.js";
@@ -824,40 +825,135 @@ async function doSnapshot(c, _url, _opts) {
824
825
  return { title, preview: text, final_url: extractRootWebAreaUrl(text) };
825
826
  }
826
827
  async function doScreenshot(c, _url, opts) {
827
- // W1-DEF-3(v1.8):chrome-devtools-mcp@0.3.0 take_screenshot filePath 参数
828
- // (被 zod strip),返回 base64 —— Lasso 自行落盘 + fs 校验后才把 path 放进返回,
829
- // 禁伪造路径。写失败 throw screenshot_write_failed(classifyBrowseError → didnt)。
830
- const r = (await c.callTool("take_screenshot", {
828
+ // E②(BUG-03 决议 E②,doc/bugs/03 §4 E,消费方②screenshot 根治)双路径:
829
+ // 路径 1(优先):传 filePath 给上游(chrome-devtools-mcp@1.7.0 已支持——
830
+ // 0.3.0 时代被 zod strip 的参数未跟进是当日两次大截图失败的根因:上游
831
+ // ≥2MB 截图只落上游临时文件不回 image block)。上游直写盘绕过 image-block
832
+ // 大小限制;lasso 管理路径(用户指定优先,否则 /tmp/lasso-screenshot-<uuid>)
833
+ // + 落盘校验 + PNG magic 保留(W1-DEF-3 禁伪造路径)。
834
+ // 路径 2(回退):上游未兑现写盘(0.3.0 形态 / filePath 被忽略)→ 既有
835
+ // image-block base64 解码落盘路径。
836
+ // 对抗复审补丁(BUG-03 adversarial r1,2026-09-08 真机实锤):上游 1.7.0 的
837
+ // filePath 带工作区根校验(McpContext.validatePath——"Access denied: path … is
838
+ // not within any of the configured workspace roots")。lasso 从未与上游协商
839
+ // roots 能力、也未配 --allow-unrestricted-paths,**任何路径(含本函数的
840
+ // /tmp 管理路径)都会被上游拒绝**——原实现 isError 即抛,image-block 回退
841
+ // 永不执行,真机上 screenshot action 100% 失败(比修复前更糟:修复前小截图
842
+ // 走 image block 可用)。修复:isError 且文本含 "Access denied"(路径 1 被
843
+ // 拒)→ 不带 filePath 重试一次(落到路径 2 的两形态:image block 或上游
844
+ // 临时文件文本行);其余 isError 维持原样如实抛。
845
+ const target = opts.screenshot?.filePath ?? `/tmp/lasso-screenshot-${randomUUID()}.png`;
846
+ let r = (await c.callTool("take_screenshot", {
831
847
  format: "png",
832
848
  fullPage: opts.screenshot?.full ?? false,
849
+ filePath: target,
833
850
  }));
834
851
  if (r.isError) {
835
- throw new Error(`screenshot_write_failed:upstream_is_error:${firstText(r) ?? "unknown"}`);
852
+ const errText = firstText(r) ?? "unknown";
853
+ if (/Access denied/.test(errText)) {
854
+ // 路径 1 被上游工作区根校验拒绝 → 重试不带 filePath(路径 2)
855
+ r = (await c.callTool("take_screenshot", {
856
+ format: "png",
857
+ fullPage: opts.screenshot?.full ?? false,
858
+ }));
859
+ if (r.isError) {
860
+ throw new Error(`screenshot_write_failed:upstream_is_error:${firstText(r) ?? "unknown"}`);
861
+ }
862
+ }
863
+ else {
864
+ throw new Error(`screenshot_write_failed:upstream_is_error:${errText.slice(0, 120)}`);
865
+ }
836
866
  }
837
- // W1-DEF-1b(v1.8):base64 type:"image" content block 的 data 字段
838
- // (实测契约见 browse/upstream-response.ts),不在 text block。
839
- const img = imageBlock(r);
840
- if (!img) {
841
- throw new Error("screenshot_write_failed:no_image_block_from_upstream");
867
+ // 用户指定路径的父目录 best-effort 创建(默认 /tmp 恒存在)
868
+ try {
869
+ await mkdir(pathMod.dirname(target), { recursive: true });
870
+ }
871
+ catch {
872
+ /* best-effort;写失败由下方落盘校验如实报 */
873
+ }
874
+ // ---- 路径 1 验证:上游兑现 filePath 直写 ----
875
+ let upstreamWrote = false;
876
+ try {
877
+ const st = await stat(target);
878
+ upstreamWrote = st.isFile() && st.size > 0;
842
879
  }
843
- const buf = Buffer.from(img.data, "base64");
844
- // PNG magic 校验(上游返回错误占位串时 base64 解出非 PNG——47 字节垃圾文件的教训)
845
- if (buf.length < 100 || buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) {
846
- throw new Error("screenshot_write_failed:not_a_valid_png");
880
+ catch {
881
+ /* 未写 走回退路径 */
882
+ }
883
+ if (!upstreamWrote) {
884
+ // ---- 路径 2 回退:image-block(0.3.0 契约形态)解码落盘 ----
885
+ // W1-DEF-1b(v1.8):base64 在 type:"image" content block 的 data 字段
886
+ // (实测契约见 browse/upstream-response.ts),不在 text block。
887
+ const img = imageBlock(r);
888
+ if (!img) {
889
+ // 对抗复审补丁(BUG-03 adversarial r1):上游 ≥2MB 截图(无 filePath 时)
890
+ // 落**上游自己的临时文件**并回文本行 "Saved screenshot to <path>"(1.7.0
891
+ // take-screenshot.js ≥2_000_000 分支)——只认 image block 会把这类成功
892
+ // 当失败(消费方②原始事故形态)。解析该行 → 读上游临时文件 → PNG magic
893
+ // 校验 → 物化到本函数的 target。读不到/非 PNG 走既有 no_image_block 拒绝。
894
+ const m = String(r.content?.map((b) => b.text ?? "").join("\n") ?? "")
895
+ .match(/Saved screenshot to (\S+)/);
896
+ const upstreamTmpPath = m?.[1]?.replace(/[.,;]+$/, ""); // 上游文本行尾句点剥除
897
+ if (upstreamTmpPath) {
898
+ let tmpBuf = null;
899
+ try {
900
+ tmpBuf = await readFile(upstreamTmpPath);
901
+ }
902
+ catch {
903
+ tmpBuf = null;
904
+ }
905
+ if (tmpBuf !== null &&
906
+ tmpBuf.length >= 100 &&
907
+ tmpBuf[0] === 0x89 && tmpBuf[1] === 0x50 && tmpBuf[2] === 0x4e && tmpBuf[3] === 0x47) {
908
+ await writeFile(target, tmpBuf);
909
+ const st2 = await stat(target);
910
+ if (!st2.isFile() || st2.size !== tmpBuf.length) {
911
+ throw new Error(`empty_or_missing_file:${target}`);
912
+ }
913
+ return { preview: `screenshot saved to ${target}` };
914
+ }
915
+ }
916
+ throw new Error("screenshot_write_failed:no_image_block_from_upstream");
917
+ }
918
+ const buf = Buffer.from(img.data, "base64");
919
+ // PNG magic 校验(上游返回错误占位串时 base64 解出非 PNG——47 字节垃圾文件的教训)
920
+ if (buf.length < 100 || buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) {
921
+ throw new Error("screenshot_write_failed:not_a_valid_png");
922
+ }
923
+ try {
924
+ await writeFile(target, buf);
925
+ // 落盘后校验:文件存在且大小与解码后一致才返路径(禁伪造)
926
+ const st = await stat(target);
927
+ if (!st.isFile() || st.size !== buf.length) {
928
+ throw new Error(`empty_or_missing_file:${target}`);
929
+ }
930
+ }
931
+ catch (e) {
932
+ throw new Error(`screenshot_write_failed:${String(e).slice(0, 200)}`);
933
+ }
847
934
  }
848
- const filePath = `/tmp/lasso-screenshot-${randomUUID()}.png`;
935
+ // ---- 终验(两路径共用):磁盘文件头 8 字节 PNG magic(上游直写路径同样
936
+ // 禁伪造——上游可能对超限/失败静默写错误占位文件)----
849
937
  try {
850
- await writeFile(filePath, buf);
851
- // 落盘后校验:文件存在且大小与解码后一致才返路径(禁伪造)
852
- const st = await stat(filePath);
853
- if (!st.isFile() || st.size !== buf.length) {
854
- throw new Error(`empty_or_missing_file:${filePath}`);
938
+ const fh = await open(target, "r");
939
+ try {
940
+ const head = Buffer.alloc(8);
941
+ const { bytesRead } = await fh.read(head, 0, 8, 0);
942
+ if (bytesRead < 8 ||
943
+ head[0] !== 0x89 || head[1] !== 0x50 || head[2] !== 0x4e || head[3] !== 0x47) {
944
+ throw new Error("screenshot_write_failed:not_a_valid_png");
945
+ }
946
+ }
947
+ finally {
948
+ await fh.close();
855
949
  }
856
950
  }
857
951
  catch (e) {
952
+ if (e instanceof Error && e.message.startsWith("screenshot_write_failed:"))
953
+ throw e;
858
954
  throw new Error(`screenshot_write_failed:${String(e).slice(0, 200)}`);
859
955
  }
860
- return { preview: `screenshot saved to ${filePath}` };
956
+ return { preview: `screenshot saved to ${target}` };
861
957
  }
862
958
  async function doExtract(c, _url, opts) {
863
959
  // ---------- v1.1 mode 分流(parse12 §3.3.1;INV-66 raw byte-identical v1.0) ----------
@@ -1040,19 +1136,28 @@ async function doWait(c, _url, opts) {
1040
1136
  async function doEvaluate(c, _url, opts) {
1041
1137
  if (!opts.js)
1042
1138
  throw new Error("evaluate: opts.js required");
1043
- // W1-DEF-1b(v1.8):MCP js 是语句体(`return ...` / 裸表达式两种都支持)——
1044
- // 包进函数体交上游调用;直接透传 `return ...` 会被当函数表达式语法错
1045
- // (wave2 smoke 实证 "Unexpected token 'return'")。
1139
+ // E④(BUG-03 决议 E④,doc/bugs/03 §4 E,消费方④根治):wrapper 形态探测归一——
1140
+ // 既有实现把 opts.js 无条件包进 `() => {\n${js}\n}`,调用方按 lasso 工具描述传
1141
+ // **函数表达式**(`() => document.title`,与上游 evaluate_script 契约一致)时被
1142
+ // 包成「函数体内的函数表达式语句」——求值不 return → **恒 undefined(静默错值)**。
1143
+ // 修复:函数表达式形态原样透传(上游自调用);语句体形态维持包裹。双兼容,
1144
+ // 工具描述同步双例(descriptions.ts evaluate action)。
1046
1145
  const r = (await c.callTool("evaluate_script", {
1047
- function: `() => {\n${opts.js}\n}`,
1146
+ function: evaluateFunctionArg(opts.js),
1048
1147
  }));
1049
1148
  // P5(v1.18.1,得到实战问题集 P5):上游错误假成功治理——与 doWait
1050
1149
  // (W-DEF-R11-1 v1.17.1 同病同修)同范式:McpClient.callTool 对 is_error 不
1051
1150
  // throw,此前不检 → 协议超时("Network.enable timed out")/ 无页面
1052
1151
  // ("No page selected")/ 脚本异常堆栈全部被当 preview 返回,outcome 恒 worked。
1053
- // isError → throw eval_upstream_error → classifyBrowseError 落 unknown(可重试)。
1152
+ // isError → throw → classifyBrowseError 落 unknown(可重试)。
1153
+ // E④(BUG-03 决议 E④):会话轮换("No page selected")在此路径也归类
1154
+ // session_rotated(透明化:可重试语义 + 提示重 snapshot),不再落泛 unknown 文案。
1054
1155
  if (r.isError) {
1055
- throw new Error(`eval_upstream_error:${(extractEvalPreview(r)).slice(0, 120)}`);
1156
+ const errText = extractEvalPreview(r);
1157
+ if (/^No page selected$/.test(errText.trim())) {
1158
+ throw new Error("session_rotated:No page selected — the session page was rotated/cleared; take a fresh snapshot then retry (retryable)");
1159
+ }
1160
+ throw new Error(`eval_upstream_error:${errText.slice(0, 120)}`);
1056
1161
  }
1057
1162
  // PERF-4(2026-09-02 perf/acc 轮 2):超限 object/array 免 parse+stringify 快路。
1058
1163
  // 上游围栏文本 = 页内 JSON.stringify(返回值)(chrome-devtools-mcp performEvaluation
@@ -1082,6 +1187,12 @@ async function doEvaluate(c, _url, opts) {
1082
1187
  // 把 evaluateHandle 异常序列化进 content 的形态;签名窄匹配防误伤正常返回值)。
1083
1188
  // 仅在 v==null(未解析出脚本值 = 响应非 ```json 围栏形态)时检查。
1084
1189
  if (v == null && UPSTREAM_EVAL_ERROR_SIGNATURES.some((re) => re.test(preview))) {
1190
+ // E④(BUG-03 决议 E④):会话轮换错误归类 session_rotated(透明化——不再落
1191
+ // 泛 unknown 文案):"No page selected" = 会话页已被轮换/清空(McpContext 整串
1192
+ // 恰为此);语义可重试(outcome=unknown),提示先重 snapshot 重建会话页。
1193
+ if (/^No page selected$/.test(preview.trim())) {
1194
+ throw new Error("session_rotated:No page selected — the session page was rotated/cleared; take a fresh snapshot then retry (retryable)");
1195
+ }
1085
1196
  throw new Error(`eval_upstream_error:${preview.slice(0, 120)}`);
1086
1197
  }
1087
1198
  return { preview: truncatePreview(preview) };
@@ -1098,6 +1209,28 @@ const UPSTREAM_EVAL_ERROR_SIGNATURES = [
1098
1209
  /^Error:[\s\S]*\bpptr:/,
1099
1210
  /^No page selected$/,
1100
1211
  ];
1212
+ /**
1213
+ * E④(BUG-03 决议 E④):evaluate 的 js 入参 → 上游 function 参数归一(导出供测试)。
1214
+ *
1215
+ * 双形态兼容:
1216
+ * - **函数表达式**(起手 `(` / `function` / `async`,或单标识符箭头 `x => x`)
1217
+ * → 原样透传——上游 evaluate_script 契约本就吃函数表达式并自调用;旧 wrapper
1218
+ * 会把它包成「函数体内的函数表达式语句」(求值不 return → 恒 undefined 静默
1219
+ * 错值——消费方④实战毒点)。
1220
+ * - **语句体**(`return ...` / 声明 / 多语句 / 裸表达式)→ 维持包裹
1221
+ * `() => { ... }`(W1-DEF-1b:直接透传 `return` 会被当函数表达式语法错,
1222
+ * wave2 smoke 实证 "Unexpected token 'return'")。
1223
+ * 判定规则刻意保守(起手 token 白名单):误判方向 = 语句体被透传(上游报语法
1224
+ * 错,响亮可修),优于函数表达式被包裹(静默 undefined,毒中之毒)。
1225
+ */
1226
+ export function evaluateFunctionArg(js) {
1227
+ const t = js.trim();
1228
+ if (/^(?:async\b|function\b|\()/.test(t))
1229
+ return t; // 函数表达式起手 token
1230
+ if (/^[A-Za-z_$][\w$]*\s*=>/.test(t))
1231
+ return t; // 单标识符箭头 x => x
1232
+ return `() => {\n${js}\n}`; // 语句体维持包裹
1233
+ }
1101
1234
  /** navigate_page 返回里偶尔带 final_url;找不到就用 undefined(上游 fallback url)。 */
1102
1235
  function extractFinalUrl(r) {
1103
1236
  const txt = firstText(r);
@@ -1201,6 +1334,11 @@ function classifyBrowseError(msg, _action) {
1201
1334
  // v1.8(W1-DEF-3 / W1-DEF-5):screenshot 落盘失败是明确「本地交付不可得」→ didnt
1202
1335
  if (m.includes("screenshot_write_failed"))
1203
1336
  return "didnt";
1337
+ // E④(BUG-03 决议 E④):会话轮换("No page selected" 类)是**瞬态可重试**信号
1338
+ //(页面被轮换/清空,重 snapshot 即恢复)——显式归 unknown(可重试档)+
1339
+ // session_rotated 前缀供 agent 透明识别下一步(重 snapshot),不再落泛 unknown
1340
+ if (m.includes("session_rotated"))
1341
+ return "unknown";
1204
1342
  if (m.includes("dns_or_nav_error"))
1205
1343
  return "unknown";
1206
1344
  if (m.includes("http_404"))