@te-river/opencode-team-mode 1.5.11 → 1.5.13

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 (112) hide show
  1. package/README.md +154 -57
  2. package/README.zh-CN.md +126 -47
  3. package/dist/agents.d.ts +5 -1
  4. package/dist/agents.d.ts.map +1 -1
  5. package/dist/agents.js +37 -11
  6. package/dist/agents.js.map +1 -1
  7. package/dist/approval-gate.d.ts +54 -16
  8. package/dist/approval-gate.d.ts.map +1 -1
  9. package/dist/approval-gate.js +163 -42
  10. package/dist/approval-gate.js.map +1 -1
  11. package/dist/host-hooks.d.ts +115 -0
  12. package/dist/host-hooks.d.ts.map +1 -0
  13. package/dist/host-hooks.js +270 -0
  14. package/dist/host-hooks.js.map +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +68 -4
  17. package/dist/index.js.map +1 -1
  18. package/dist/prompts/lead.d.ts +1 -1
  19. package/dist/prompts/lead.d.ts.map +1 -1
  20. package/dist/prompts/lead.js +88 -6
  21. package/dist/prompts/lead.js.map +1 -1
  22. package/dist/prompts/shared.d.ts +1 -1
  23. package/dist/prompts/shared.d.ts.map +1 -1
  24. package/dist/prompts/shared.js +203 -119
  25. package/dist/prompts/shared.js.map +1 -1
  26. package/dist/prompts/specialists.d.ts +2 -2
  27. package/dist/prompts/specialists.d.ts.map +1 -1
  28. package/dist/prompts/specialists.js +73 -23
  29. package/dist/prompts/specialists.js.map +1 -1
  30. package/dist/tm/args-schema.d.ts +31 -1
  31. package/dist/tm/args-schema.d.ts.map +1 -1
  32. package/dist/tm/args-schema.js +155 -39
  33. package/dist/tm/args-schema.js.map +1 -1
  34. package/dist/tm/bash-timeout.d.ts +72 -0
  35. package/dist/tm/bash-timeout.d.ts.map +1 -0
  36. package/dist/tm/bash-timeout.js +100 -0
  37. package/dist/tm/bash-timeout.js.map +1 -0
  38. package/dist/tm/browser.d.ts +250 -20
  39. package/dist/tm/browser.d.ts.map +1 -1
  40. package/dist/tm/browser.js +1200 -143
  41. package/dist/tm/browser.js.map +1 -1
  42. package/dist/tm/config.d.ts +129 -0
  43. package/dist/tm/config.d.ts.map +1 -1
  44. package/dist/tm/config.js +117 -1
  45. package/dist/tm/config.js.map +1 -1
  46. package/dist/tm/dispatch.d.ts +95 -0
  47. package/dist/tm/dispatch.d.ts.map +1 -0
  48. package/dist/tm/dispatch.js +369 -0
  49. package/dist/tm/dispatch.js.map +1 -0
  50. package/dist/tm/index.d.ts +32 -7
  51. package/dist/tm/index.d.ts.map +1 -1
  52. package/dist/tm/index.js +74 -5
  53. package/dist/tm/index.js.map +1 -1
  54. package/dist/tm/memory.d.ts +88 -10
  55. package/dist/tm/memory.d.ts.map +1 -1
  56. package/dist/tm/memory.js +881 -99
  57. package/dist/tm/memory.js.map +1 -1
  58. package/dist/tm/pipelines.d.ts +26 -3
  59. package/dist/tm/pipelines.d.ts.map +1 -1
  60. package/dist/tm/pipelines.js +167 -11
  61. package/dist/tm/pipelines.js.map +1 -1
  62. package/dist/tm/ptc/budgets.d.ts +22 -1
  63. package/dist/tm/ptc/budgets.d.ts.map +1 -1
  64. package/dist/tm/ptc/budgets.js +87 -8
  65. package/dist/tm/ptc/budgets.js.map +1 -1
  66. package/dist/tm/ptc/contract.d.ts +49 -9
  67. package/dist/tm/ptc/contract.d.ts.map +1 -1
  68. package/dist/tm/ptc/contract.js +41 -3
  69. package/dist/tm/ptc/contract.js.map +1 -1
  70. package/dist/tm/ptc/driver.d.ts +5 -2
  71. package/dist/tm/ptc/driver.d.ts.map +1 -1
  72. package/dist/tm/ptc/driver.js +47 -15
  73. package/dist/tm/ptc/driver.js.map +1 -1
  74. package/dist/tm/ptc/engines.d.ts +15 -4
  75. package/dist/tm/ptc/engines.d.ts.map +1 -1
  76. package/dist/tm/ptc/engines.js +281 -28
  77. package/dist/tm/ptc/engines.js.map +1 -1
  78. package/dist/tm/ptc/index.d.ts +4 -4
  79. package/dist/tm/ptc/index.d.ts.map +1 -1
  80. package/dist/tm/ptc/index.js +2 -2
  81. package/dist/tm/ptc/index.js.map +1 -1
  82. package/dist/tm/ptc/pscan.d.ts +20 -1
  83. package/dist/tm/ptc/pscan.d.ts.map +1 -1
  84. package/dist/tm/ptc/pscan.js +328 -14
  85. package/dist/tm/ptc/pscan.js.map +1 -1
  86. package/dist/tm/ptc/summary.d.ts.map +1 -1
  87. package/dist/tm/ptc/summary.js +23 -1
  88. package/dist/tm/ptc/summary.js.map +1 -1
  89. package/dist/tm/ptc/tool.d.ts +34 -3
  90. package/dist/tm/ptc/tool.d.ts.map +1 -1
  91. package/dist/tm/ptc/tool.js +117 -20
  92. package/dist/tm/ptc/tool.js.map +1 -1
  93. package/dist/tm/pty.d.ts +70 -0
  94. package/dist/tm/pty.d.ts.map +1 -0
  95. package/dist/tm/pty.js +242 -0
  96. package/dist/tm/pty.js.map +1 -0
  97. package/dist/tm/search.d.ts +128 -15
  98. package/dist/tm/search.d.ts.map +1 -1
  99. package/dist/tm/search.js +556 -47
  100. package/dist/tm/search.js.map +1 -1
  101. package/dist/tm/shell-bridge.d.ts +14 -1
  102. package/dist/tm/shell-bridge.d.ts.map +1 -1
  103. package/dist/tm/shell-bridge.js +57 -10
  104. package/dist/tm/shell-bridge.js.map +1 -1
  105. package/dist/tm/webfetch.d.ts +29 -7
  106. package/dist/tm/webfetch.d.ts.map +1 -1
  107. package/dist/tm/webfetch.js +117 -15
  108. package/dist/tm/webfetch.js.map +1 -1
  109. package/dist/types.d.ts +16 -0
  110. package/dist/types.d.ts.map +1 -1
  111. package/docs/installation.md +216 -17
  112. package/package.json +5 -2
@@ -1,56 +1,113 @@
1
1
  /**
2
2
  * tm_browser — governed interactive browser (Plan C: the user's own
3
- * Chromium-family browser driven headful over the CDP pipe protocol).
3
+ * Chromium-family browser, headful by default on desktops).
4
4
  *
5
- * Feasibility proven by smoke (Edge + --remote-debugging-pipe + JSON/NUL
6
- * framing over fds 3/4 — zero deps, no WebSocket implementation). Qoder's
7
- * hardening ideas are ported where the plugin can reach them:
8
- * - isolated temp user-data-dir (never the user's real profile);
9
- * - per-navigation allowlist enforced at the NETWORK layer via CDP
10
- * Fetch.requestPaused (non-allowlisted hosts get BlockedByClient —
11
- * the per-hop re-check analog);
12
- * - hardened webPreferences are the spawn's own flags (no-first-run,
13
- * no-extensions, mute);
14
- * - hard per-command timeout + explicit close; dispose kills the child.
5
+ * T5 engine split (design board ② "TM_BROWSER_ENGINE" + R2 verdict):
6
+ * - PRIMARY engine: playwright-core — dynamic import ONLY (the package is
7
+ * declared in dependencies but NOT installed at build time; a static
8
+ * import would break tsc). A failed import or node < 20 degrades the
9
+ * session to the cdp-legacy engine automatically, per plugin instance.
10
+ * - cdp-legacy engine: the original hand-rolled CDP pipe transport
11
+ * (JSON + NUL framing over fds 3/4, zero deps — smoke-proven) kept
12
+ * verbatim as the fallback. On cdp-legacy only the core subset works
13
+ * (open/navigate/read/screenshot/close + navigate_page/take_screenshot/
14
+ * list_pages/evaluate_script aliases); the snapshot-first actions
15
+ * (take_snapshot + uid addressing, 16-verb chrome-devtools-mcp surface)
16
+ * require the playwright engine.
17
+ *
18
+ * Action surface aligns with chrome-devtools-mcp's 16 verbs (R2 mapping):
19
+ * navigate_page · take_snapshot · click · fill · hover · drag ·
20
+ * press_key · select_page · upload_file · wait_for · evaluate_script ·
21
+ * list_console_messages · list_network_requests · list_pages ·
22
+ * take_screenshot · handle_dialog
23
+ * plus the legacy compatibility verbs open / navigate / read /
24
+ * screenshot / close. Snapshot-first discipline: take_snapshot returns
25
+ * the ariaSnapshot YAML with an INJECTED [uid=eN] per node (playwright has
26
+ * no uid concept — SnapshotIndex below is our own snapshot→locator
27
+ * registry); follow-up actions address nodes by that uid (or an explicit
28
+ * selector escape hatch) instead of guessed locators.
29
+ *
30
+ * Hardening (unchanged from the CDP era, ported to both engines):
31
+ * - isolated temp profile (never the user's real browser profile);
32
+ * persistent login is ONLY via an explicit TM_BROWSER_USER_DATA_DIR;
33
+ * - domain allowlist enforced at the NETWORK layer on the NAVIGATION
34
+ * (playwright context.route / CDP Fetch interception), re-checked on
35
+ * every redirect hop; SUBRESOURCES then follow TM_BROWSER_SUBRESOURCE
36
+ * (default same-site: passive types load, an executable resource loads
37
+ * when it belongs to a site this session opened). Gating every request
38
+ * by the content allowlist is what made pages render picture-less — the
39
+ * 21 seeded hosts contain no CDN, so img/css/js died silently;
40
+ * - out-of-allowlist open/navigate routes through the OFFICIAL dialog
41
+ * BEFORE any spawn; approved hosts also pass the network layer;
42
+ * - hardened context: real-Chrome UA + zh-CN Accept-Language,
43
+ * no-first-run / no-extensions / mute launch flags, 3 s default action
44
+ * timeout (the prompt's "3000 ms budget" is the tool's default, not an
45
+ * aspiration), no networkidle waits anywhere;
46
+ * - dispose AWAITS the close, and an idle session reaps itself
47
+ * (TM_BROWSER_IDLE_MS) — an orphaned visible window is a user-facing bug.
15
48
  *
16
49
  * Environment adaptivity (different OpenCode hosts):
17
50
  * - headful by default (Plan C); display-less Linux (no DISPLAY/
18
51
  * WAYLAND_DISPLAY) automatically falls back to headless; TM_BROWSER_
19
52
  * HEADLESS=1|0 forces either way;
20
- * - browser discovery: TM_BROWSER_PATH override, then the USER'S DEFAULT
21
- * browser (Windows registry / Linux xdg-settings) when it is
22
- * Chromium-family — CDP's pipe protocol is Chromium-only, so Firefox as
23
- * default falls through — then per-OS candidate paths (Edge first on
24
- * Windows; Chrome/Chromium elsewhere). No browser → structured error,
25
- * the agent falls back to tm_webfetch / user MCP tools.
53
+ * - browser discovery (SHARED by both engines): TM_BROWSER_PATH
54
+ * override, then the USER'S DEFAULT browser (Windows registry / Linux
55
+ * xdg-settings) when it is Chromium-family — CDP is Chromium-proprietary
56
+ * and playwright channels only speak Chromium too, so a Firefox default
57
+ * falls through — then per-OS candidate paths (Edge first on Windows;
58
+ * Chrome/Chromium elsewhere). No browser → structured error, the agent
59
+ * falls back to tm_webfetch / user MCP tools.
26
60
  *
27
- * Role access mirrors tm_webfetch: network roles (team + researcher) only.
61
+ * Role access mirrors tm_webfetch: network roles (team + researcher) only,
62
+ * tester browser-only (UI verification).
28
63
  */
29
64
  import { execFileSync, spawn } from "node:child_process";
30
65
  import * as fs from "node:fs";
31
66
  import * as os from "node:os";
32
67
  import * as path from "node:path";
33
- import { checkWebUrl } from "./webfetch.js";
68
+ import { checkWebUrl, seedWebfetchDomains } from "./webfetch.js";
34
69
  import { askUserForTarget } from "./perm-ask.js";
70
+ import { assertReadablePath } from "./guard.js";
71
+ import { isEnvFilePath } from "../envprotect.js";
35
72
  import { tmError, toToolResult } from "./result.js";
73
+ import { estimateTokens } from "./config.js";
36
74
  import { rmForceSafe } from "../fs-safe.js";
37
- /** Per-OS browser candidates, in preference order (first hit wins). */
75
+ /** Per-OS browser candidates, in preference order (first hit wins). ONLY a
76
+ * fallback: on Windows the registry probe below is what actually honours the
77
+ * user's chosen channel. Edge/Chrome ship each channel in its OWN install
78
+ * directory (`Microsoft\EdgeBeta`, `EdgeDev`, `EdgeCanary`), so the stable
79
+ * path alone let a Beta-default host fall back to stable Edge (observed on
80
+ * Windows 11, 2026-09-18). */
38
81
  const BROWSER_CANDIDATES = {
39
82
  win32: [
40
83
  "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
41
84
  "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
85
+ "C:\\Program Files (x86)\\Microsoft\\Edge Beta\\Application\\msedge.exe",
86
+ "C:\\Program Files\\Microsoft\\Edge Beta\\Application\\msedge.exe",
87
+ "C:\\Program Files (x86)\\Microsoft\\Edge Dev\\Application\\msedge.exe",
88
+ "C:\\Program Files\\Microsoft\\Edge Dev\\Application\\msedge.exe",
89
+ "C:\\Program Files (x86)\\Microsoft\\Edge Canary\\Application\\msedge.exe",
90
+ "C:\\Program Files\\Microsoft\\Edge Canary\\Application\\msedge.exe",
42
91
  "%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe",
43
92
  "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
44
93
  "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
94
+ "%LOCALAPPDATA%\\Google\\Chrome Beta\\Application\\chrome.exe",
95
+ "%LOCALAPPDATA%\\Google\\Chrome Dev\\Application\\chrome.exe",
96
+ "%LOCALAPPDATA%\\Google\\Chrome SxS\\Application\\chrome.exe",
45
97
  ],
46
98
  darwin: [
47
99
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
48
100
  "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
101
+ "/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
102
+ "/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
103
+ "/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary",
49
104
  "/Applications/Chromium.app/Contents/MacOS/Chromium",
50
105
  ],
51
106
  linux: [
52
107
  "/usr/bin/google-chrome",
53
108
  "/usr/bin/google-chrome-stable",
109
+ "/usr/bin/google-chrome-beta",
110
+ "/usr/bin/google-chrome-unstable",
54
111
  "/usr/bin/chromium-browser",
55
112
  "/usr/bin/chromium",
56
113
  "/snap/bin/chromium",
@@ -68,7 +125,9 @@ export function parseProgId(stdout) {
68
125
  const m = /ProgId\s+REG_SZ\s+(\S+)/i.exec(String(stdout ?? ""));
69
126
  return m ? m[1] : null;
70
127
  }
71
- /** Parse `reg query "...\shell\open\command" /ve` output → exe path. */
128
+ /** Parse `reg query "...\shell\open\command" /ve` output → exe path.
129
+ * The quoted capture stops at the closing quote, so the `" -- %1"` style
130
+ * URL-template tail never leaks into the returned path. */
72
131
  export function parseRegCommand(stdout) {
73
132
  const m = /REG_SZ\s+(?:"([^"]+)"|(\S+))/i.exec(String(stdout ?? ""));
74
133
  return m ? (m[1] ?? m[2]) : null;
@@ -78,24 +137,84 @@ export function parseDesktopExec(text) {
78
137
  const m = /^Exec\s*=\s*(?:"([^"]+)"|(\S+))/m.exec(String(text ?? ""));
79
138
  return m ? (m[1] ?? m[2]) : null;
80
139
  }
140
+ /** Registry roots that can carry a ProgId's `shell\open\command`. Edge is
141
+ * installed machine-wide (HKLM\SOFTWARE\Classes) while a per-user Chrome
142
+ * install registers HKCU\Software\Classes — probing HKCU only is what made
143
+ * a Beta-default host come back empty and fall through to the stable
144
+ * candidate path. */
145
+ const REG_CLASSES_ROOTS = ["HKCU\\Software\\Classes", "HKLM\\SOFTWARE\\Classes"];
146
+ /** Both URL schemes the shell association is recorded under. A user can
147
+ * set the two differently, so `http` alone is not "the default browser". */
148
+ const REG_URL_SCHEMES = ["http", "https"];
149
+ /** Channel install dirs keyed off the ProgId name (`MSEdgeBetaHTM`,
150
+ * `ChromeHTML`, …) — the last-resort probe when the launch command itself is
151
+ * unreadable but the ProgId still names the channel. NOTE the real Windows
152
+ * Edge dirs carry a SPACE ("Microsoft\Edge Beta"), which is what an
153
+ * `EdgeBeta` guess silently misses; and Chrome installs per-user under
154
+ * %LOCALAPPDATA% with a different exe name. */
155
+ const CHANNEL_INSTALL_BY_PROGID = [
156
+ [/edgebeta/i, "C:\\Program Files (x86)\\Microsoft\\Edge Beta\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge Beta\\Application\\msedge.exe"],
157
+ [/edgedev/i, "C:\\Program Files (x86)\\Microsoft\\Edge Dev\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge Dev\\Application\\msedge.exe"],
158
+ [/edgecanary|edgeappcanary/i, "C:\\Program Files (x86)\\Microsoft\\Edge Canary\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge Canary\\Application\\msedge.exe"],
159
+ [/msedge|edgehtm/i, "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"],
160
+ [/chromebeta/i, "%LOCALAPPDATA%\\Google\\Chrome Beta\\Application\\chrome.exe", "C:\\Program Files\\Google\\Chrome Beta\\Application\\chrome.exe"],
161
+ [/chromedev|chromejsx/i, "%LOCALAPPDATA%\\Google\\Chrome Dev\\Application\\chrome.exe", "C:\\Program Files\\Google\\Chrome Dev\\Application\\chrome.exe"],
162
+ [/chromehtml|chromechtml/i, "%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"],
163
+ ];
164
+ /** Resolve a ProgId to its executable via every classes root. `reg query`
165
+ * exits non-zero (throws) for a missing key, so each probe is wrapped. */
166
+ function progIdToExecutable(progId, run) {
167
+ for (const root of REG_CLASSES_ROOTS) {
168
+ try {
169
+ const exe = parseRegCommand(run("reg", ["query", `${root}\\${progId}\\shell\\open\\command`, "/ve"]));
170
+ if (exe)
171
+ return exe;
172
+ }
173
+ catch {
174
+ /* next root */
175
+ }
176
+ }
177
+ return null;
178
+ }
81
179
  /** The user's DEFAULT browser, when it is Chromium-family and present.
82
180
  * null → the caller falls back to the per-OS probe list (detection is
83
181
  * best-effort and must never throw into tool discovery). */
84
182
  export function defaultBrowserExecutable(env = process.env, run = defaultRunner) {
85
183
  try {
86
184
  if (process.platform === "win32") {
87
- const choice = run("reg", [
88
- "query",
89
- "HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice",
90
- "/v",
91
- "ProgId",
92
- ]);
93
- const progId = parseProgId(choice);
94
- if (!progId)
95
- return null;
96
- const cmd = run("reg", ["query", `HKCU\\Software\\Classes\\${progId}\\shell\\open\\command`, "/ve"]);
97
- const exe = parseRegCommand(cmd);
98
- return exe && isChromiumFamily(exe) && fs.existsSync(exe) ? exe : null;
185
+ const accept = (exe) => exe && isChromiumFamily(exe) && fs.existsSync(exe) ? exe : null;
186
+ for (const scheme of REG_URL_SCHEMES) {
187
+ let progId = null;
188
+ try {
189
+ progId = parseProgId(run("reg", [
190
+ "query",
191
+ `HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\${scheme}\\UserChoice`,
192
+ "/v",
193
+ "ProgId",
194
+ ]));
195
+ }
196
+ catch {
197
+ progId = null;
198
+ }
199
+ if (!progId)
200
+ continue;
201
+ const direct = accept(progIdToExecutable(progId, run));
202
+ if (direct)
203
+ return direct;
204
+ // launch command unreadable (or points at a Firefox shim) — the
205
+ // ProgId itself still names the channel, so probe that channel's
206
+ // standard install dir before giving up on the user's default.
207
+ for (const [re, ...cands] of CHANNEL_INSTALL_BY_PROGID) {
208
+ if (!re.test(progId))
209
+ continue;
210
+ for (const cand of cands) {
211
+ const hit = accept(cand.replace(/%LOCALAPPDATA%/gi, String(env.LOCALAPPDATA ?? "").trim()));
212
+ if (hit)
213
+ return hit;
214
+ }
215
+ }
216
+ }
217
+ return null;
99
218
  }
100
219
  if (process.platform === "linux") {
101
220
  const desk = run("xdg-settings", ["get", "default-web-browser"]).trim();
@@ -122,6 +241,23 @@ export function defaultBrowserExecutable(env = process.env, run = defaultRunner)
122
241
  }
123
242
  return null;
124
243
  }
244
+ /** Playwright's `channel` launch IGNORES the executable we discovered —
245
+ * `channel:"msedge"` resolves whatever playwright thinks the stable Edge is,
246
+ * so a correct Edge-Beta discovery still opened stable (the decisive half of
247
+ * the 2026-09-18 report). The channel spelling is therefore only correct
248
+ * for the exact stable-channel install dir; everything else (Beta/Dev/
249
+ * Canary, portable, an explicit TM_BROWSER_PATH) rides on executablePath. */
250
+ export function playwrightLaunchTarget(executable, opts = {}) {
251
+ const exe = String(executable ?? "");
252
+ const p = exe.replace(/\//g, "\\").toLowerCase();
253
+ if (!opts.explicitOverride) {
254
+ if (/\\microsoft\\edge\\application\\msedge\.exe$/.test(p))
255
+ return { executablePath: exe, channel: "msedge" };
256
+ if (/\\google\\chrome\\application\\chrome\.exe$/.test(p))
257
+ return { executablePath: exe, channel: "chrome" };
258
+ }
259
+ return { executablePath: exe };
260
+ }
125
261
  /** Resolve the browser executable: TM_BROWSER_PATH override → the user's
126
262
  * DEFAULT browser (Chromium-family only) → per-OS candidate paths.
127
263
  * %LOCALAPPDATA% style vars are expanded on win32. */
@@ -165,13 +301,90 @@ export function resolveHeadless(env = process.env) {
165
301
  return !env.DISPLAY && !env.WAYLAND_DISPLAY;
166
302
  return false;
167
303
  }
304
+ // ---------- subresource gate (the "pages render without images" fix) --------
305
+ /** A resource the page cannot execute code with — it can only paint. */
306
+ const PASSIVE_RESOURCE_TYPES = new Set(["image", "media", "font", "stylesheet"]);
307
+ /** Public suffixes that make the last TWO labels insufficient for a site
308
+ * comparison. Deliberately a small spelled-out list (no PSL dependency):
309
+ * an unlisted multi-label suffix yields a LONGER site key, which auto-passes
310
+ * FEWER hosts — the conservative direction for a network gate. */
311
+ const TWO_LABEL_SUFFIXES = new Set([
312
+ "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn", "co.cn", "com.hk", "org.hk", "co.jp", "ne.jp",
313
+ "co.kr", "co.uk", "org.uk", "ac.uk", "com.au", "net.au", "org.au", "co.in", "com.br", "com.mx", "com.tr",
314
+ "com.ua", "com.sg", "com.my", "com.tw", "co.za", "com.pl", "com.ru", "com.vn", "com.id", "com.ar", "com.co",
315
+ ]);
316
+ /** Approximate registrable site (eTLD+1) of a hostname. */
317
+ export function siteOf(host) {
318
+ const h = String(host ?? "").toLowerCase().replace(/\.$/, "");
319
+ if (!h || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(h))
320
+ return h;
321
+ const labels = h.split(".").filter(Boolean);
322
+ if (labels.length <= 2)
323
+ return labels.join(".");
324
+ const lastTwo = labels.slice(-2).join(".");
325
+ if (TWO_LABEL_SUFFIXES.has(lastTwo))
326
+ return labels.slice(-3).join(".");
327
+ return lastTwo;
328
+ }
329
+ /** playwright and CDP spell resource types differently (`Image` vs `image`,
330
+ * `Stylesheet` vs `stylesheet`) — one normalizer keeps both engines on the
331
+ * same verdict table. */
332
+ export function normalizeResourceType(raw) {
333
+ const t = String(raw ?? "").trim().toLowerCase();
334
+ return t === "" ? "other" : t;
335
+ }
336
+ export function isPassiveResource(raw) {
337
+ return PASSIVE_RESOURCE_TYPES.has(normalizeResourceType(raw));
338
+ }
339
+ /**
340
+ * One subresource verdict, shared VERBATIM by both engines so playwright and
341
+ * cdp-legacy can never drift. `allowedSites` holds the registrable sites of
342
+ * every host this session actually navigated to (allowlist hit or dialog
343
+ * approval), which is what makes a page's own JS/CSS work without opening
344
+ * the whole internet: the governance boundary moves from "21 content domains"
345
+ * to "the site you approved", and an off-site SCRIPT still needs a dialog.
346
+ */
347
+ export function subresourcePass(opts) {
348
+ if (opts.allowlistHit)
349
+ return { pass: true, via: "allowlist" };
350
+ if (opts.approvedHost)
351
+ return { pass: true, via: "approved" };
352
+ if (opts.policy === "off")
353
+ return { pass: false, via: "blocked" };
354
+ let host;
355
+ try {
356
+ host = new URL(opts.url).hostname;
357
+ }
358
+ catch {
359
+ return { pass: false, via: "blocked" };
360
+ }
361
+ const type = normalizeResourceType(opts.resourceType);
362
+ if (PASSIVE_RESOURCE_TYPES.has(type))
363
+ return { pass: true, via: `passive:${type}` };
364
+ if (opts.policy === "passive")
365
+ return { pass: false, via: `blocked:${type}` };
366
+ return opts.allowedSites.has(siteOf(host)) ? { pass: true, via: `same-site:${type}` } : { pass: false, via: `blocked:${type}` };
367
+ }
368
+ /** A navigation (or a host the dialog approved) widens the subresource
369
+ * footprint to its own site. Called for EVERY top-level/document request
370
+ * that passed the allowlist side. */
371
+ export function rememberSite(allowedSites, url) {
372
+ try {
373
+ const host = new URL(url).hostname;
374
+ if (host)
375
+ allowedSites.add(siteOf(host));
376
+ }
377
+ catch {
378
+ /* unparseable — nothing to remember */
379
+ }
380
+ }
168
381
  class PipeCdp {
169
382
  child;
170
383
  nextId = 1;
171
384
  pending = new Map();
172
385
  buf = Buffer.alloc(0);
173
386
  events = [];
174
- onEvent = (handler) => this.eventHandler = handler;
387
+ onEvent = (handler) => (this.eventHandler = handler);
175
388
  eventHandler = () => { };
176
389
  constructor(child) {
177
390
  this.child = child;
@@ -230,28 +443,374 @@ class PipeCdp {
230
443
  }
231
444
  }
232
445
  }
446
+ /** playwright-core 1.63 targets node >= 20; on anything older we refuse the
447
+ * engine up-front and stay on the zero-dependency CDP pipe (the node>=18
448
+ * package floor keeps the plugin itself loadable — only the playwright
449
+ * PRIMARY is gated here, cdp-legacy carries node 18/19 hosts). */
450
+ export const PLAYWRIGHT_MIN_NODE_MAJOR = 20;
451
+ /** "v22.3.1" / "20" -> major number; unparseable -> 0 (fail toward the
452
+ * always-available cdp-legacy engine). */
453
+ export function nodeMajorOf(version) {
454
+ const m = /^v?(\d+)/.exec(String(version ?? "").trim());
455
+ return m ? Number(m[1]) : 0;
456
+ }
457
+ /** The REAL loader — a VARIABLE specifier so tsc never tries to resolve
458
+ * "playwright-core" at build time (types ship inside the package, which may
459
+ * be absent from node_modules). Under ESM, import(variable) yields the
460
+ * module namespace typed as any. */
461
+ export async function defaultImportPlaywright() {
462
+ const specifier = "playwright-core";
463
+ return await import(specifier);
464
+ }
465
+ /** Pure engine-selection seam (exported for test-browser.mjs):
466
+ * cdp-legacy preference → legacy WITHOUT ever importing; node < 20 →
467
+ * legacy without importing; playwright preference → import, verify the
468
+ * chromium.launch shape, degrade to legacy on ANY failure. */
469
+ export async function selectBrowserEngine(opts) {
470
+ const preference = opts.preference === "cdp-legacy" ? "cdp-legacy" : "playwright";
471
+ if (preference === "cdp-legacy") {
472
+ return { kind: "cdp-legacy", reason: "TM_BROWSER_ENGINE=cdp-legacy 偏好" };
473
+ }
474
+ if (opts.nodeMajor < PLAYWRIGHT_MIN_NODE_MAJOR) {
475
+ return {
476
+ kind: "cdp-legacy",
477
+ reason: `node ${opts.nodeMajor || "?"} < ${PLAYWRIGHT_MIN_NODE_MAJOR} — playwright-core 不受支持,自动降级 cdp-legacy`,
478
+ };
479
+ }
480
+ try {
481
+ const mod = (await opts.importPlaywright());
482
+ if (!mod || typeof mod.chromium?.launch !== "function") {
483
+ throw new Error("playwright-core 模块形状异常(缺 chromium.launch)");
484
+ }
485
+ return { kind: "playwright", pw: mod };
486
+ }
487
+ catch (e) {
488
+ return {
489
+ kind: "cdp-legacy",
490
+ reason: `playwright-core 加载失败:${e?.message ?? String(e)} — 自动降级 cdp-legacy(快照类动作不可用)`,
491
+ };
492
+ }
493
+ }
494
+ /** ariaSnapshot yields plain YAML with NO addressable id, while the
495
+ * chrome-devtools-mcp muscle memory this tool aligns to is uid-addressed.
496
+ * The missing layer is built here: each take_snapshot re-indexes every YAML
497
+ * node (`- <role> "<name>" …`), injects `[uid=eN]` inline, and remembers
498
+ * (role, name, nth-of-kind); later actions resolve uid →
499
+ * `page.getByRole(role, { name, exact: true }).nth(nth)`. Uids are
500
+ * renumbered on every snapshot (one action, one observation); an
501
+ * unknown/stale uid errors back to take_snapshot instead of falling back
502
+ * to guessed locators. */
503
+ export class SnapshotIndex {
504
+ entries = new Map();
505
+ next = 0;
506
+ get size() {
507
+ return this.entries.size;
508
+ }
509
+ /** Reset + parse + annotate the ariaSnapshot YAML in one pass. */
510
+ annotate(yaml) {
511
+ this.entries.clear();
512
+ this.next = 0;
513
+ const counters = new Map();
514
+ const out = [];
515
+ for (const line of String(yaml ?? "").split(/\r?\n/)) {
516
+ // `- role`, `- role "name"`, `- role:`, `- role "name" [attrs]` …
517
+ // the `text` pseudo-role carries no interactive semantics: skip it.
518
+ const m = /^(\s*-\s+)([A-Za-z][\w-]*)((?:"[^"]*"|.)*?)$/.exec(line);
519
+ if (!m || m[2] === "text") {
520
+ out.push(line);
521
+ continue;
522
+ }
523
+ const role = m[2];
524
+ const rest = m[3];
525
+ const uid = `e${++this.next}`;
526
+ const rawName = /"((?:[^"\\]|\\.)*)"/.exec(rest)?.[1];
527
+ const name = rawName === undefined ? undefined : unescapeAriaName(rawName);
528
+ const key = `${role}\u0000${name ?? ""}`;
529
+ const nth = counters.get(key) ?? 0;
530
+ counters.set(key, nth + 1);
531
+ this.entries.set(uid, { role, name, nth });
532
+ // container lines (`- list:`) get the uid BEFORE the colon so the
533
+ // YAML `key:` child-anchor semantics stay intact; names may contain
534
+ // colons, hence the startsWith check instead of a split.
535
+ out.push(rest.startsWith(":") ? `${m[1]}${role} [uid=${uid}]${rest}` : `${line} [uid=${uid}]`);
536
+ }
537
+ return out.join("\n");
538
+ }
539
+ get(uid) {
540
+ return this.entries.get(String(uid ?? "").trim());
541
+ }
542
+ /** uid → playwright locator; null = unknown uid / role the engine cannot
543
+ * address (the caller renders the take_snapshot-first guidance). */
544
+ locatorFor(page, uid) {
545
+ const entry = this.get(uid);
546
+ if (!entry)
547
+ return null;
548
+ try {
549
+ const base = entry.name !== undefined ? page.getByRole(entry.role, { name: entry.name, exact: true }) : page.getByRole(entry.role);
550
+ return base.nth(entry.nth);
551
+ }
552
+ catch {
553
+ return null;
554
+ }
555
+ }
556
+ }
557
+ /** ariaSnapshot escapes whitespace/quotes inside names (\s \n \t \" \\). */
558
+ export function unescapeAriaName(raw) {
559
+ return String(raw).replace(/\\(.)/g, (_, c) => (c === "s" ? " " : c === "n" ? "\n" : c === "t" ? "\t" : c));
560
+ }
561
+ // ---------- action surface (R2: chrome-devtools-mcp 16-verb alignment) --------
562
+ /** The 16 canonical verbs, named byte-identical to chrome-devtools-mcp. */
563
+ export const BROWSER_PLAYWRIGHT_ACTIONS = [
564
+ "navigate_page",
565
+ "take_snapshot",
566
+ "click",
567
+ "fill",
568
+ "hover",
569
+ "drag",
570
+ "press_key",
571
+ "select_page",
572
+ "upload_file",
573
+ "wait_for",
574
+ "evaluate_script",
575
+ "list_console_messages",
576
+ "list_network_requests",
577
+ "list_pages",
578
+ "take_screenshot",
579
+ "handle_dialog",
580
+ ];
581
+ /** Legacy compat verbs that ride on top of the 16 (both engines). */
582
+ export const BROWSER_COMPAT_ACTIONS = ["open", "navigate", "read", "screenshot", "close"];
583
+ /** What the cdp-legacy fallback can still do (R2: degradation must keep the
584
+ * §6o behavior; everything snapshot-shaped is playwright-only). */
585
+ export const BROWSER_LEGACY_ACTIONS = [
586
+ "open",
587
+ "navigate",
588
+ "navigate_page",
589
+ "read",
590
+ "screenshot",
591
+ "take_screenshot",
592
+ "list_pages",
593
+ "evaluate_script",
594
+ "close",
595
+ ];
596
+ /** Default action/wait budget — the prompt's "3000 ms" discipline is the
597
+ * ENGINE default, so agents that forget a timeout still fail fast. */
598
+ const PW_DEFAULT_TIMEOUT_MS = 3000;
599
+ const OBSERVE_BUFFER_CAP = 200;
600
+ const HARDENED_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
233
601
  const SPAWN_TIMEOUT_MS = 15_000;
234
602
  const NAVIGATE_EVENT_TIMEOUT_MS = 15_000;
235
- // ---------- tool ----------------------------------------------------------------
236
603
  export function buildTmBrowserTool(deps) {
237
604
  const { pipelines } = deps;
238
605
  const env = deps.env ?? process.env;
606
+ const notify = typeof deps.notify === "function" ? deps.notify : undefined;
239
607
  const tool = "tm_browser";
240
608
  const traj = (e) => pipelines.store.appendTrajectory({ tool, ...e });
241
609
  let session = null;
242
- const allowlist = deps.cfg.webfetchAllowedDomains
243
- ?? ["*"];
610
+ // m2 (fix round): SAME seed policy as tm_webfetch / tm_search — the
611
+ // built-in default list gains the T4 engine home hosts (api.stackexchange
612
+ // .com, hn.algolia.com) so jump-reading a search hit stops re-popping the
613
+ // dialog; a narrowed TM_WEBFETCH_ALLOWED_DOMAINS is never widened.
614
+ const allowlist = seedWebfetchDomains(deps.cfg.webfetchAllowedDomains ?? ["*"]);
244
615
  // hosts approved through the OFFICIAL dialog this plugin lifetime — the
245
- // CDP network-layer block consults this in addition to the static allowlist
616
+ // network layer (both engines) consults this in addition to the static allowlist
246
617
  const approvedHosts = new Set();
247
- async function ensureSession(headless) {
248
- if (session)
249
- return session;
250
- const executable = findBrowserExecutable(env);
251
- if (!executable) {
252
- throw new Error("本机未找到可用的浏览器(按 Edge/Chrome/Chromium 顺序探测失败)。" +
253
- "设置 TM_BROWSER_PATH 指向浏览器可执行文件,或改用 tm_webfetch / 用户 MCP 工具。");
618
+ const snapshotBudget = Math.max(10, Number(deps.cfg?.browserSnapshotMaxTokens) || 1200);
619
+ const subPolicy = deps.cfg?.browserSubresource ?? "same-site";
620
+ const discover = deps.findExecutable ?? findBrowserExecutable;
621
+ const imageMaxBytes = Math.max(1000, Number(deps.cfg?.browserImageMaxBytes) || 400_000);
622
+ const idleCloseMs = Number(deps.cfg?.browserIdleCloseMs ?? 180_000);
623
+ // ---------- engine selection (cached per plugin instance) ----------
624
+ let selPromise = null;
625
+ let lastSel = null;
626
+ const preference = String(deps.cfg?.browserEngine ?? "playwright");
627
+ const selection = () => {
628
+ if (!selPromise) {
629
+ selPromise = selectBrowserEngine({
630
+ preference,
631
+ nodeMajor: deps.nodeMajor ?? nodeMajorOf(process.versions.node),
632
+ importPlaywright: deps.importPlaywright ?? defaultImportPlaywright,
633
+ }).then((sel) => {
634
+ lastSel = sel;
635
+ traj({ step_id: "browser", event: "engine", kind: sel.kind, reason: (sel.reason ?? "").slice(0, 200) });
636
+ return sel;
637
+ });
638
+ }
639
+ return selPromise;
640
+ };
641
+ /** Observe-payload cap shared by snapshot / console / network / eval. */
642
+ function capTokens(text, note) {
643
+ if (estimateTokens(text) <= snapshotBudget)
644
+ return text;
645
+ const lines = text.split("\n");
646
+ let acc = 0;
647
+ let i = 0;
648
+ for (; i < lines.length; i++) {
649
+ const cost = estimateTokens(lines[i]) + 1;
650
+ if (acc + cost > snapshotBudget)
651
+ break;
652
+ acc += cost;
653
+ }
654
+ return `${lines.slice(0, i).join("\n")}\n${note}`;
655
+ }
656
+ function saveShotPng(bytes, stepId) {
657
+ const png = Buffer.from(bytes);
658
+ const dir = path.join(pipelines.store.stepsRoot(), stepId);
659
+ fs.mkdirSync(dir, { recursive: true });
660
+ const file = path.join(dir, "screenshot.png");
661
+ fs.writeFileSync(file, png);
662
+ traj({ step_id: stepId, event: "result", bytes: png.length });
663
+ return { file, png };
664
+ }
665
+ /** LLM args are booleans most of the time, but `"false"` / `"0"` strings
666
+ * are common — a bare `Boolean()` read both as TRUE, which is exactly how
667
+ * a model used to force tm_browser into headless (and get anti-bot
668
+ * blocked) on a desktop that defaults to headful. */
669
+ function isTrueArg(raw) {
670
+ if (raw === true)
671
+ return true;
672
+ if (typeof raw === "number")
673
+ return raw === 1;
674
+ if (typeof raw === "string")
675
+ return /^(1|true|yes|on)$/i.test(raw.trim());
676
+ return false;
677
+ }
678
+ /** Screenshot line + the OPT-IN pixels.
679
+ * The PNG always lands in the run store (it is the evidence artifact);
680
+ * what rides back to the MODEL is a quality-70 JPEG — a real page with
681
+ * images loaded is ~1.5 MB as PNG, which no token budget should ever
682
+ * inline, and ~150-300 KB as JPEG. Pixels stay opt-in so a UI sweep
683
+ * cannot spend context the agent never asked for. */
684
+ async function shotOutput(sess, file, png, args, captureJpeg) {
685
+ const head = `截图已保存(PNG ${png.length} bytes):${file}`;
686
+ if (!isTrueArg(args.image)) {
687
+ return `${head}\n(上下文只携带路径,不携带像素——需要真正看到画面时带 image:true 再截一次。)`;
688
+ }
689
+ let img = null;
690
+ if (captureJpeg) {
691
+ try {
692
+ img = await captureJpeg();
693
+ }
694
+ catch {
695
+ img = null;
696
+ }
697
+ }
698
+ let kind = "jpeg";
699
+ if (!img) {
700
+ img = png;
701
+ kind = "png";
702
+ }
703
+ if (img.length > imageMaxBytes) {
704
+ return `${head}\n(未附带像素:${kind.toUpperCase()} ${img.length} bytes 超过 TM_BROWSER_IMAGE_MAX_BYTES=${imageMaxBytes}。改用视口截图(去掉 fullPage)或提高该上限。)`;
705
+ }
706
+ sess.attachments.push({
707
+ type: "file",
708
+ mime: `image/${kind}`,
709
+ url: `data:image/${kind};base64,${img.toString("base64")}`,
710
+ filename: path.basename(file).replace(/\.png$/, `.${kind === "jpeg" ? "jpg" : "png"}`),
711
+ });
712
+ return `${head}\n(像素以 ${kind.toUpperCase()} ${img.length} bytes 随本条结果附带。)`;
713
+ }
714
+ /** One line telling the agent that the page is NOT actually empty — the
715
+ * subresource gate dropped N requests. Without this the model reads a
716
+ * blank-looking page and reports "该网站没有图片". */
717
+ function blockedNote(sess) {
718
+ if (!sess.blocked.count)
719
+ return "";
720
+ const n = sess.blocked.count;
721
+ const hosts = [...sess.blocked.hosts];
722
+ // RESET IN PLACE — the engine's route handler holds this very object, so
723
+ // swapping in a new one would silently stop counting after the first
724
+ // drain (and the note would never appear again for the session).
725
+ sess.blocked.count = 0;
726
+ sess.blocked.hosts.clear();
727
+ traj({ step_id: "browser", event: "blocked", count: n, hosts: hosts.slice(0, 20).join(",") });
728
+ return (`\n注意:本页有 ${n} 个子资源请求被治理白名单拦截(${hosts.slice(0, 6).join(", ")}${hosts.length > 6 ? ` …+${hosts.length - 6}` : ""})——不是站点没有内容。` +
729
+ `当前策略 TM_BROWSER_SUBRESOURCE=${subPolicy};同域资源已自动放行,跨域脚本仍需白名单或弹窗批准。`);
730
+ }
731
+ function pageTextOutput(url, text) {
732
+ return `页面文本(${url}):\n${text.slice(0, 20000)}${text.length > 20000 ? "\n…(截断)" : ""}`;
733
+ }
734
+ function discoveryError() {
735
+ return new Error("本机未找到可用的浏览器(按 Edge/Chrome/Chromium 顺序探测失败)。" +
736
+ "设置 TM_BROWSER_PATH 指向浏览器可执行文件,或改用 tm_webfetch / 用户 MCP 工具。");
737
+ }
738
+ /**
739
+ * M1 (upload_file exfil): the domain allowlist constrains the network
740
+ * DESTINATION but never the local SOURCE — an unguarded filePath could
741
+ * upload .env / key files to a whitelisted host's form. Reuses the read
742
+ * pipeline's P2 containment verbatim (workspace root + blackboard +
743
+ * trajectory; realpath-verified, fail-closed on unresolvable paths) and
744
+ * then applies the R6 env-file classifier, so *.env / shell-rc files are
745
+ * refused INSIDE the root too. The env refusal echoes no path (R6 privacy
746
+ * line); the scope verdict keeps the tm_read/tm_grep message shape.
747
+ */
748
+ function uploadExfilGuard(args, ctx) {
749
+ const raw = (args.filePath ?? args.files);
750
+ const files = (Array.isArray(raw) ? raw : [raw]).map((f) => String(f ?? "").trim()).filter(Boolean);
751
+ if (!files.length)
752
+ return null; // "缺少 filePath" stays act()'s own args error
753
+ const d = ctx?.directory;
754
+ const root = typeof d === "string" && d ? d : process.cwd();
755
+ const extra = [pipelines.store.blackboardRoot, pipelines.store.trajectoryRoot].filter((p) => typeof p === "string" && p.length > 0);
756
+ for (const f of files) {
757
+ if (isEnvFilePath(f))
758
+ return "拒绝上传(R6 红线:.env / shell rc 家族文件永不离开本机)";
759
+ const scope = assertReadablePath(root, f, extra);
760
+ if (!scope.ok)
761
+ return scope.message;
762
+ }
763
+ return null;
764
+ }
765
+ // ================= cdp-legacy engine (原手写 CDP 管道,保留不删) =============
766
+ /**
767
+ * Shared network verdict for BOTH engines — one implementation, so the
768
+ * playwright and cdp-legacy legs can never disagree about what passes.
769
+ * The hard red lines (non-http scheme, remote env-file spelling) stay hard
770
+ * under EVERY subresource policy: they are not consentable, so a passive
771
+ * image or a same-site script can never relax them.
772
+ */
773
+ function gateDecide(st, url, resourceType) {
774
+ const verdict = checkWebUrl(url, allowlist);
775
+ if (!verdict.ok && !verdict.askable)
776
+ return { pass: false, via: "red-line" };
777
+ let approved = false;
778
+ try {
779
+ approved = approvedHosts.has(new URL(url).hostname);
780
+ }
781
+ catch {
782
+ approved = false;
783
+ }
784
+ const v = subresourcePass({
785
+ url,
786
+ resourceType,
787
+ policy: subPolicy,
788
+ allowlistHit: verdict.ok,
789
+ approvedHost: approved,
790
+ allowedSites: st.allowedSites,
791
+ });
792
+ if (v.pass) {
793
+ if (normalizeResourceType(resourceType) === "document")
794
+ rememberSite(st.allowedSites, url);
795
+ }
796
+ else {
797
+ st.blocked.count++;
798
+ try {
799
+ st.blocked.hosts.add(new URL(url).hostname);
800
+ }
801
+ catch {
802
+ /* no host to name */
803
+ }
254
804
  }
805
+ // NO per-request trajectory: a real page is hundreds of requests and the
806
+ // log would be pure noise. Blocked verdicts aggregate into st.blocked
807
+ // and flush as ONE line (blockedNote / close).
808
+ return v;
809
+ }
810
+ async function openLegacySession(headless) {
811
+ const executable = discover(env);
812
+ if (!executable)
813
+ throw discoveryError();
255
814
  const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-browser-"));
256
815
  const child = spawn(executable, [
257
816
  headless ? "--headless=new" : "--start-maximized",
@@ -259,7 +818,7 @@ export function buildTmBrowserTool(deps) {
259
818
  `--user-data-dir=${profileDir}`,
260
819
  // real-Chrome UA even under --headless=new (which some sites detect
261
820
  // via the "HeadlessChrome" token otherwise) + zh-CN Accept-Language
262
- "--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
821
+ `--user-agent=${HARDENED_UA}`,
263
822
  "--accept-lang=zh-CN,zh;q=0.9,en;q=0.8",
264
823
  "--no-first-run", "--no-default-browser-check", "--disable-extensions",
265
824
  "--disable-background-networking", "--mute-audio",
@@ -286,36 +845,115 @@ export function buildTmBrowserTool(deps) {
286
845
  const page = targets.targetInfos.find((t) => t.type === "page");
287
846
  if (!page)
288
847
  throw new Error("浏览器启动后未找到 page target");
289
- const { sessionId } = await cdp.call("Target.attachToTarget", { targetId: page.targetId, flatten: true });
848
+ const { sessionId } = (await cdp.call("Target.attachToTarget", { targetId: page.targetId, flatten: true }));
290
849
  await cdp.call("Page.enable", {}, sessionId);
291
- // network-layer allowlist enforcement (Qoder's per-hop re-check analog)
850
+ // network-layer enforcement — the SAME gateDecide the playwright leg uses
851
+ // (subresource policy + per-hop redirect re-check + dialog-approved hosts)
852
+ const allowedSites = new Set();
853
+ const blocked = { count: 0, hosts: new Set() };
292
854
  await cdp.call("Fetch.enable", { patterns: [{ urlPattern: "*" }] }, sessionId);
293
855
  cdp.onEvent((m) => {
294
856
  if (m.method !== "Fetch.requestPaused" || m.sessionId !== sessionId)
295
857
  return;
296
858
  const requestId = String(m.params.requestId ?? "");
297
- const url = String(m.params.request?.url ?? "");
298
- const verdict = checkWebUrl(url, allowlist);
299
- let pass = verdict.ok;
300
- if (!pass) {
301
- // an approved-via-dialog host passes the network layer too
302
- try {
303
- pass = approvedHosts.has(new URL(url).hostname);
304
- }
305
- catch {
306
- pass = false;
307
- }
308
- }
859
+ const p = m.params.request ?? {};
860
+ const url = String(p.url ?? "");
861
+ const { pass } = gateDecide({ allowedSites, blocked }, url, p.resourceType);
309
862
  if (pass) {
310
863
  void cdp.call("Fetch.continueRequest", { requestId }, sessionId, 5000).catch(() => { });
311
864
  }
312
865
  else {
313
- traj({ step_id: "browser", event: "blocked", url: url.slice(0, 200) });
314
866
  void cdp.call("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }, sessionId, 5000).catch(() => { });
315
867
  }
316
868
  });
317
- child.on("exit", () => {
318
- session = null;
869
+ const sess = {
870
+ kind: "cdp-legacy",
871
+ currentUrl: "about:blank",
872
+ headless,
873
+ label: `cdp-legacy · ${path.basename(executable)} · pid ${child.pid ?? "?"}`,
874
+ attachments: [],
875
+ blocked,
876
+ allowedSites,
877
+ async act(action, args, stepId) {
878
+ if (action === "navigate" || action === "navigate_page") {
879
+ const url = String(args.url ?? "").trim();
880
+ rememberSite(allowedSites, url);
881
+ await cdp.call("Page.navigate", { url }, sessionId);
882
+ await cdp.waitEvent("Page.loadEventFired", sessionId, NAVIGATE_EVENT_TIMEOUT_MS).catch(() => { });
883
+ sess.currentUrl = url;
884
+ return `已导航:${url}${blockedNote(sess)}`;
885
+ }
886
+ if (action === "read") {
887
+ traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
888
+ const ev = await cdp.call("Runtime.evaluate", { expression: "document.body ? document.body.innerText : ''", returnByValue: true }, sessionId);
889
+ const text = String(ev.result?.value ?? "");
890
+ traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
891
+ return `${pageTextOutput(sess.currentUrl, text)}${blockedNote(sess)}`;
892
+ }
893
+ if (action === "screenshot" || action === "take_screenshot") {
894
+ traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
895
+ const shot = await cdp.call("Page.captureScreenshot", { format: "png" }, sessionId);
896
+ const png = Buffer.from(String(shot.data ?? ""), "base64");
897
+ const { file } = saveShotPng(new Uint8Array(png), stepId);
898
+ return shotOutput(sess, file, png, args, async () => {
899
+ const j = await cdp.call("Page.captureScreenshot", { format: "jpeg", quality: 70 }, sessionId);
900
+ return Buffer.from(String(j.data ?? ""), "base64");
901
+ });
902
+ }
903
+ if (action === "list_pages") {
904
+ const t = await cdp.call("Target.getTargets");
905
+ const pages = t.targetInfos.filter((x) => x.type === "page");
906
+ const lines = pages.map((p, i) => `${i}: ${String(p.url ?? "")} ${String(p.title ?? "")}`.trimEnd());
907
+ return `标签页(${pages.length}):\n${lines.join("\n") || "(无)"}`;
908
+ }
909
+ if (action === "evaluate_script") {
910
+ const src = String(args.function ?? args.expression ?? "").trim();
911
+ if (!src)
912
+ throw new Error('缺少 expression/function 参数(evaluate_script 需要一个 JS 表达式)');
913
+ traj({ step_id: stepId, event: "call", kind: "evaluate" });
914
+ const ev = await cdp.call("Runtime.evaluate", { expression: src, returnByValue: true }, sessionId);
915
+ const value = ev.result?.value;
916
+ return capTokens(`执行结果:${JSON.stringify(value ?? null)}`, "…(结果超长已截断)");
917
+ }
918
+ throw new Error(`action "${action}" 需要 playwright 引擎(当前为 cdp-legacy 降级:playwright-core 未安装或 node<20)。` +
919
+ `cdp-legacy 可用: ${BROWSER_LEGACY_ACTIONS.filter((a) => a !== "open" && a !== "close").join(" | ")}`);
920
+ },
921
+ async close() {
922
+ child.off?.("exit", onExit);
923
+ await cdp.call("Browser.close", {}, undefined, 3000).catch(() => { });
924
+ if (child.exitCode === null) {
925
+ child.kill();
926
+ }
927
+ // wait briefly for the process to release the profile (Windows file
928
+ // locks linger while the browser is still shutting down)
929
+ await new Promise((res) => {
930
+ if (child.exitCode !== null)
931
+ return res();
932
+ const t = setTimeout(res, 3000);
933
+ child.once("exit", () => {
934
+ clearTimeout(t);
935
+ res();
936
+ });
937
+ });
938
+ const dead = child.exitCode !== null;
939
+ try {
940
+ rmForceSafe(profileDir, { recursive: true });
941
+ }
942
+ catch {
943
+ /* a locked leftover temp dir is reclaimed by the OS — never fail close */
944
+ }
945
+ return dead
946
+ ? { closed: true, text: "浏览器进程已退出(cdp-legacy),临时配置目录已清理。" }
947
+ : {
948
+ closed: false,
949
+ text: `警告:cdp-legacy 子进程 pid ${child.pid ?? "?"} 未在 3s 内退出,窗口可能仍在前台——` +
950
+ `请手动关闭该浏览器窗口(临时目录 ${profileDir})。`,
951
+ };
952
+ },
953
+ };
954
+ const onExit = () => {
955
+ if (session === sess)
956
+ session = null;
319
957
  // best-effort: the profile may still be file-locked during shutdown —
320
958
  // a leftover temp dir is reclaimed by the OS, never an error for the task
321
959
  try {
@@ -324,38 +962,422 @@ export function buildTmBrowserTool(deps) {
324
962
  catch {
325
963
  /* ignore */
326
964
  }
327
- });
328
- session = { child, cdp, sessionId, profileDir, currentUrl: "about:blank" };
329
- return session;
965
+ };
966
+ child.on("exit", onExit);
967
+ return sess;
330
968
  }
331
- async function closeSession() {
332
- if (!session)
333
- return "没有打开的浏览器会话。";
334
- const { child, cdp, profileDir } = session;
335
- session = null;
336
- await cdp.call("Browser.close", {}, undefined, 3000).catch(() => { });
337
- if (child.exitCode === null) {
338
- child.kill();
969
+ // ================= playwright engine (primary) ==============================
970
+ async function openPlaywrightSession(pw, headless) {
971
+ const executable = discover(env);
972
+ if (!executable)
973
+ throw discoveryError();
974
+ const launchArgs = [
975
+ "--no-first-run", "--no-default-browser-check", "--disable-extensions",
976
+ "--disable-background-networking", "--mute-audio",
977
+ ...(headless ? [] : ["--start-maximized"]),
978
+ ];
979
+ const contextOpts = {
980
+ userAgent: HARDENED_UA,
981
+ locale: "zh-CN",
982
+ extraHTTPHeaders: { "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" },
983
+ // the 3000 ms observation budget is the engine default (see header)
984
+ timeout: PW_DEFAULT_TIMEOUT_MS,
985
+ // headful: honor --start-maximized (null viewport = the OS window size)
986
+ viewport: headless ? undefined : null,
987
+ };
988
+ const persistentDir = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim();
989
+ // R2 (revised 2026-09-18): playwright's `channel` resolves the executable
990
+ // ITSELF, which silently overrode our discovery (a Beta-default Windows
991
+ // host opened STABLE Edge). executablePath is now the primary launch and
992
+ // the channel spelling only a fallback — the opposite of the old order.
993
+ const target = playwrightLaunchTarget(executable, {
994
+ explicitOverride: String(env.TM_BROWSER_PATH ?? "").trim() !== "",
995
+ });
996
+ let browser = null;
997
+ let context;
998
+ let via = "executablePath";
999
+ if (persistentDir) {
1000
+ context = await pw.chromium.launchPersistentContext(persistentDir, {
1001
+ ...contextOpts,
1002
+ executablePath: target.executablePath,
1003
+ headless,
1004
+ args: launchArgs,
1005
+ });
1006
+ via = `persistent(executablePath)`;
1007
+ }
1008
+ else {
1009
+ try {
1010
+ browser = await pw.chromium.launch({ executablePath: target.executablePath, headless, args: launchArgs });
1011
+ via = "executablePath";
1012
+ }
1013
+ catch (e1) {
1014
+ if (!target.channel)
1015
+ throw e1;
1016
+ browser = await pw.chromium.launch({ channel: target.channel, headless, args: launchArgs });
1017
+ via = `channel(${target.channel})`;
1018
+ }
1019
+ context = await browser.newContext(contextOpts);
339
1020
  }
340
- // wait briefly for the process to release the profile (Windows file
341
- // locks linger while the browser is still shutting down)
342
- await new Promise((res) => {
343
- if (child.exitCode !== null)
344
- return res();
345
- const t = setTimeout(res, 3000);
346
- child.once("exit", () => {
347
- clearTimeout(t);
348
- res();
1021
+ const snapIndex = new SnapshotIndex();
1022
+ const state = {
1023
+ page: context.pages()[0] ?? (await context.newPage()),
1024
+ dialogs: [],
1025
+ consoleBuf: [],
1026
+ netBuf: [],
1027
+ allowedSites: new Set(),
1028
+ blocked: { count: 0, hosts: new Set() },
1029
+ };
1030
+ const attached = new WeakSet();
1031
+ const attach = (p) => {
1032
+ if (attached.has(p))
1033
+ return; // select_page may bounce back to an already-hooked tab
1034
+ attached.add(p);
1035
+ p.on("console", (msg) => {
1036
+ state.consoleBuf.push({ type: String(msg?.type?.() ?? "log"), text: String(msg?.text?.() ?? "") });
1037
+ if (state.consoleBuf.length > OBSERVE_BUFFER_CAP)
1038
+ state.consoleBuf.shift();
349
1039
  });
350
- });
1040
+ p.on("pageerror", (err) => {
1041
+ state.consoleBuf.push({ type: "pageerror", text: String(err?.message ?? err) });
1042
+ if (state.consoleBuf.length > OBSERVE_BUFFER_CAP)
1043
+ state.consoleBuf.shift();
1044
+ });
1045
+ p.on("request", (req) => {
1046
+ state.netBuf.push({ method: req?.method?.() ?? "?", url: String(req?.url?.() ?? "").slice(0, 300) });
1047
+ if (state.netBuf.length > OBSERVE_BUFFER_CAP)
1048
+ state.netBuf.shift();
1049
+ });
1050
+ p.on("response", (res) => {
1051
+ state.netBuf.push({
1052
+ method: res?.request?.()?.method?.() ?? "?",
1053
+ url: String(res?.url?.() ?? "").slice(0, 300),
1054
+ status: Number(res?.status?.() ?? 0) || undefined,
1055
+ });
1056
+ if (state.netBuf.length > OBSERVE_BUFFER_CAP)
1057
+ state.netBuf.shift();
1058
+ });
1059
+ p.on("requestfailed", (req) => {
1060
+ state.netBuf.push({ method: req?.method?.() ?? "?", url: String(req?.url?.() ?? "").slice(0, 300), failure: "requestfailed" });
1061
+ if (state.netBuf.length > OBSERVE_BUFFER_CAP)
1062
+ state.netBuf.shift();
1063
+ });
1064
+ // dialogs are HELD for handle_dialog (registering the handler opts us
1065
+ // out of playwright's implicit auto-dismiss)
1066
+ p.on("dialog", (d) => {
1067
+ state.dialogs.push(d);
1068
+ if (state.dialogs.length > 8)
1069
+ state.dialogs.shift();
1070
+ });
1071
+ };
1072
+ attach(state.page);
1073
+ // ---- network-layer allowlist: context.route() (CDP Fetch 的迁移面) ----
1074
+ const routeHandler = async (route, request) => {
1075
+ const u = String(request.url?.() ?? "");
1076
+ // data:/blob:/about: never reach playwright's route anyway; keep the
1077
+ // non-http hard-reject semantics identical to the CDP-era behavior
1078
+ if (/^(about|data|blob):/i.test(u)) {
1079
+ await route.continue().catch(() => { });
1080
+ return;
1081
+ }
1082
+ const { pass } = gateDecide(state, u, request.resourceType?.());
1083
+ if (pass) {
1084
+ await route.continue().catch(() => { });
1085
+ }
1086
+ else {
1087
+ // per-hop re-check lands here for every redirect hop too
1088
+ state.netBuf.push({ method: request.method?.() ?? "?", url: u.slice(0, 300), blocked: true });
1089
+ await route.abort().catch(() => { });
1090
+ }
1091
+ };
1092
+ // M2 (fix round) fail-closed: the route gate is playwright's analog of
1093
+ // the CDP leg's AWAITED Fetch.enable — if registration fails the session
1094
+ // would browse with NO network whitelist. Close the context (persistent:
1095
+ // the only handle; launched: plus the browser) and surface an execute
1096
+ // error; a swallowed .catch(()=>{}) was a fail-open hole.
351
1097
  try {
352
- rmForceSafe(profileDir, { recursive: true });
1098
+ await Promise.resolve(context.route("**/*", routeHandler));
353
1099
  }
354
- catch {
355
- /* a locked leftover temp dir is reclaimed by the OS — never fail close */
1100
+ catch (e) {
1101
+ await Promise.resolve(context.close()).catch(() => { });
1102
+ if (browser)
1103
+ await Promise.resolve(browser.close()).catch(() => { });
1104
+ traj({ step_id: "browser", event: "gate_register_failed", reason: String(e?.message ?? e).slice(0, 200) });
1105
+ throw new Error(`网络白名单闸注册失败(context.route)——已 fail-closed 关闭浏览器会话,不会留下无白名单的窗口。` +
1106
+ `原因:${String(e?.message ?? e)}`);
356
1107
  }
357
- return "浏览器会话已关闭,临时配置目录已清理。";
1108
+ /** uid → locator with the snapshot-first guidance errors (R2: 禁猜
1109
+ * locator — only uid addressing or an explicit selector escape). */
1110
+ const targetOf = (uidKey, selKey, args) => {
1111
+ const uid = String(args[uidKey] ?? "").trim();
1112
+ if (uid) {
1113
+ const loc = snapIndex.locatorFor(state.page, uid);
1114
+ if (!loc) {
1115
+ throw new Error(`uid "${uid}" 不在当前快照中(快照会重新编号)——先 action:"take_snapshot" 再用返回的 [uid=...] 寻址`);
1116
+ }
1117
+ return loc;
1118
+ }
1119
+ const sel = String(args[selKey] ?? "").trim();
1120
+ if (sel)
1121
+ return state.page.locator(sel);
1122
+ throw new Error(`缺少 ${uidKey}/selector——tm_browser 快照优先:先 action:"take_snapshot",动作只按快照里的 [uid=...] 寻址(或显式 selector 逃生舱),禁止猜 locator`);
1123
+ };
1124
+ /** Human-readable addressing echo for action results (no full locators
1125
+ * into context — uid tokens only). */
1126
+ const describeTarget = (args, uidKey = "uid") => {
1127
+ const uid = String(args[uidKey] ?? "").trim();
1128
+ if (uid)
1129
+ return `uid "${uid}"`;
1130
+ const sel = String(args[uidKey === "uid" ? "selector" : "targetSelector"] ?? "").trim();
1131
+ return sel ? `selector "${sel.slice(0, 60)}"` : "目标";
1132
+ };
1133
+ const sess = {
1134
+ kind: "playwright",
1135
+ currentUrl: "about:blank",
1136
+ headless,
1137
+ label: `playwright/${via} · ${path.basename(executable)} · ${headless ? "headless" : "headful"}`,
1138
+ attachments: [],
1139
+ blocked: state.blocked,
1140
+ allowedSites: state.allowedSites,
1141
+ async act(action, args, stepId) {
1142
+ if (action === "navigate" || action === "navigate_page") {
1143
+ const url = String(args.url ?? "").trim();
1144
+ // the navigation itself already cleared the allowlist (execute()
1145
+ // asked the user otherwise) — its SITE may now load subresources
1146
+ rememberSite(state.allowedSites, url);
1147
+ // domcontentloaded ONLY — networkidle is banned (prompt discipline)
1148
+ await state.page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
1149
+ sess.currentUrl = state.page.url?.() || url;
1150
+ rememberSite(state.allowedSites, sess.currentUrl);
1151
+ return `已导航:${url}${blockedNote(sess)}`;
1152
+ }
1153
+ if (action === "read") {
1154
+ traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
1155
+ const text = String((await state.page.evaluate("document.body ? document.body.innerText : ''")) ?? "");
1156
+ traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
1157
+ return `${pageTextOutput(sess.currentUrl, text)}${blockedNote(sess)}`;
1158
+ }
1159
+ if (action === "screenshot" || action === "take_screenshot") {
1160
+ traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
1161
+ const bytes = await state.page.screenshot({ type: "png", fullPage: Boolean(args.fullPage) });
1162
+ const { file, png } = saveShotPng(bytes, stepId);
1163
+ return shotOutput(sess, file, png, args, async () => Buffer.from(await state.page.screenshot({ type: "jpeg", quality: 70, fullPage: Boolean(args.fullPage) })));
1164
+ }
1165
+ if (action === "take_snapshot") {
1166
+ traj({ step_id: stepId, event: "call", kind: "snapshot", url: sess.currentUrl.slice(0, 200) });
1167
+ let yaml;
1168
+ try {
1169
+ yaml = await state.page.locator("body").ariaSnapshot();
1170
+ }
1171
+ catch (e) {
1172
+ throw new Error(`ariaSnapshot 不可用:${e.message} —— locator.ariaSnapshot 需要 playwright-core ≥1.49(本包 optionalDependencies 锁 1.63);未安装或过旧会走 cdp-legacy 降级,那边没有快照动作。`);
1173
+ }
1174
+ const annotated = snapIndex.annotate(yaml);
1175
+ traj({ step_id: stepId, event: "result", tokens: estimateTokens(annotated), nodes: snapIndex.size });
1176
+ const body = capTokens(annotated, `…(快照超过 browserSnapshotMaxTokens=${snapshotBudget},后续行已截断——用 read 拿原文或先滚动再快照)`);
1177
+ const hint = state.dialogs.length ? `\n注意:有 ${state.dialogs.length} 个未处理对话框——先 handle_dialog` : "";
1178
+ return `ARIA 快照(${sess.currentUrl} · ${snapIndex.size} 个可寻址节点)——后续动作按行内 [uid=eN] 寻址:\n${body}${hint}${blockedNote(sess)}`;
1179
+ }
1180
+ if (action === "click") {
1181
+ const loc = targetOf("uid", "selector", args);
1182
+ await loc.click();
1183
+ return `已点击 ${describeTarget(args)}${state.dialogs.length ? ";有未处理对话框——handle_dialog(同轮观察,勿另起动作)" : ""}`;
1184
+ }
1185
+ if (action === "fill") {
1186
+ const loc = targetOf("uid", "selector", args);
1187
+ const text = String(args.text ?? "");
1188
+ await loc.fill(text);
1189
+ return `已填充 ${describeTarget(args)}(${text.length} 字符)`;
1190
+ }
1191
+ if (action === "hover") {
1192
+ const loc = targetOf("uid", "selector", args);
1193
+ await loc.hover();
1194
+ return `已悬停 ${describeTarget(args)}`;
1195
+ }
1196
+ if (action === "drag") {
1197
+ const from = targetOf("uid", "selector", args);
1198
+ const to = targetOf("targetUid", "targetSelector", args);
1199
+ await from.dragTo(to);
1200
+ return `已拖拽 ${describeTarget(args, "uid")} → ${describeTarget(args, "targetUid")}`;
1201
+ }
1202
+ if (action === "press_key") {
1203
+ const key = String(args.key ?? "").trim();
1204
+ if (!key)
1205
+ throw new Error('缺少 key 参数(如 "Enter"、"Control+A")');
1206
+ await state.page.keyboard.press(key);
1207
+ return `已按键:${key}`;
1208
+ }
1209
+ if (action === "upload_file") {
1210
+ const loc = targetOf("uid", "selector", args);
1211
+ const raw = args.filePath ?? args.files;
1212
+ const files = (Array.isArray(raw) ? raw : [raw]).map((f) => String(f ?? "").trim()).filter(Boolean);
1213
+ if (!files.length)
1214
+ throw new Error("缺少 filePath 参数(文件或文件数组,本机路径)");
1215
+ for (const f of files) {
1216
+ if (!fs.existsSync(f))
1217
+ throw new Error(`上传文件不存在:${f.slice(0, 200)}`);
1218
+ }
1219
+ await loc.setInputFiles(files);
1220
+ return `已上传 ${files.length} 个文件到 ${describeTarget(args)}`;
1221
+ }
1222
+ if (action === "wait_for") {
1223
+ const uid = String(args.uid ?? "").trim();
1224
+ const text = String(args.text ?? "").trim();
1225
+ const timeout = Math.min(30_000, Math.max(100, Number(args.timeoutMs) || PW_DEFAULT_TIMEOUT_MS));
1226
+ const what = uid ? `uid "${uid}"` : text ? `文本 "${text.slice(0, 80)}"` : "";
1227
+ if (!uid && !text)
1228
+ throw new Error('wait_for 需要 text 或 uid');
1229
+ const loc = uid ? targetOf("uid", "selector", { uid }) : state.page.getByText(text);
1230
+ await loc.waitFor({ state: "visible", timeout });
1231
+ return `等待命中:${what}(≤${timeout}ms 内可见)`;
1232
+ }
1233
+ if (action === "evaluate_script") {
1234
+ const src = String(args.function ?? args.expression ?? "").trim();
1235
+ if (!src)
1236
+ throw new Error('缺少 expression/function 参数(JS 函数源或表达式)');
1237
+ traj({ step_id: stepId, event: "call", kind: "evaluate" });
1238
+ const value = await state.page.evaluate(src);
1239
+ return capTokens(`执行结果:${JSON.stringify(value ?? null)}`, "…(结果超长已截断)");
1240
+ }
1241
+ if (action === "list_pages") {
1242
+ const pages = context.pages();
1243
+ const lines = [];
1244
+ for (let i = 0; i < pages.length; i++) {
1245
+ const p = pages[i];
1246
+ const title = await Promise.resolve(p.title?.()).catch(() => "");
1247
+ lines.push(`${i}${p === state.page ? "(当前)" : ""}: ${String(p.url?.() ?? "")} ${String(title ?? "")}`.trimEnd());
1248
+ }
1249
+ return `标签页(${pages.length}):\n${lines.join("\n") || "(无)"}\n用 select_page { index } 切换。`;
1250
+ }
1251
+ if (action === "select_page") {
1252
+ const idx = Number(args.index);
1253
+ const pages = context.pages();
1254
+ if (!Number.isInteger(idx) || idx < 0 || idx >= pages.length) {
1255
+ throw new Error(`index 越界(0..${pages.length - 1})——先 list_pages`);
1256
+ }
1257
+ state.page = pages[idx];
1258
+ attach(state.page);
1259
+ await Promise.resolve(state.page.bringToFront?.()).catch(() => { });
1260
+ sess.currentUrl = String(state.page.url?.() ?? "");
1261
+ return `已切换到标签页 ${idx}:${sess.currentUrl}`;
1262
+ }
1263
+ if (action === "list_console_messages") {
1264
+ const lines = state.consoleBuf.map((m, i) => `${i + 1}. [${m.type}] ${m.text.slice(0, 300)}`);
1265
+ if (args.clear)
1266
+ state.consoleBuf.length = 0;
1267
+ return capTokens(`控制台消息(缓冲 ${lines.length}/${OBSERVE_BUFFER_CAP}):\n${lines.join("\n") || "(无)"}`, "…(消息超长已截断)");
1268
+ }
1269
+ if (action === "list_network_requests") {
1270
+ const lines = state.netBuf.map((r, i) => `${i + 1}. ${r.method} ${r.url}${r.status !== undefined ? ` → ${r.status}` : ""}${r.blocked ? " [被白名单拦截]" : r.failure ? ` [${r.failure}]` : ""}`);
1271
+ return capTokens(`网络请求(缓冲 ${lines.length}/${OBSERVE_BUFFER_CAP}):\n${lines.join("\n") || "(无)"}`, "…(列表超长已截断)");
1272
+ }
1273
+ if (action === "handle_dialog") {
1274
+ const d = state.dialogs.shift();
1275
+ if (!d)
1276
+ return "没有待处理的对话框(弹窗会在 click/submit 后出现——出现时 take_snapshot 的头部会提示,再 handle_dialog)。";
1277
+ const verdict = String(args.dialogAction ?? "accept").trim().toLowerCase();
1278
+ if (verdict === "dismiss") {
1279
+ await d.dismiss();
1280
+ }
1281
+ else {
1282
+ await d.accept(typeof args.promptText === "string" ? args.promptText : undefined);
1283
+ }
1284
+ return `已处理对话框(${d.type()}:「${d.message().slice(0, 120)}」→ ${verdict === "dismiss" ? "dismiss" : "accept"})`;
1285
+ }
1286
+ throw new Error(`playwright 引擎未知 action "${action}"`);
1287
+ },
1288
+ async close() {
1289
+ state.dialogs.length = 0;
1290
+ const errs = [];
1291
+ const fail = (label2) => (e) => {
1292
+ errs.push(`${label2}: ${String(e?.message ?? e)}`);
1293
+ };
1294
+ await Promise.resolve(context.close()).catch(fail("context"));
1295
+ if (browser)
1296
+ await Promise.resolve(browser.close()).catch(fail("browser"));
1297
+ // VERIFY before claiming — an agent quotes this line to the user as
1298
+ // "浏览器已关闭", so a swallowed failure must not read like success.
1299
+ let alivePages = 0;
1300
+ try {
1301
+ alivePages = context.pages().length;
1302
+ }
1303
+ catch {
1304
+ alivePages = 0;
1305
+ }
1306
+ const connected = browser ? Boolean(browser.isConnected?.()) : false;
1307
+ const closed = errs.length === 0 && alivePages === 0 && !connected;
1308
+ const profile = persistentDir
1309
+ ? `持久配置目录已保留(下次 open 复用登录态):${persistentDir}`
1310
+ : "临时配置目录由 playwright 自行回收";
1311
+ if (closed)
1312
+ return { closed: true, text: `浏览器会话已确认关闭(${sess.label});${profile}。` };
1313
+ return {
1314
+ closed: false,
1315
+ text: `警告:关闭未完全成功(${sess.label}${errs.length ? `:${errs.join("; ")}` : ""})——` +
1316
+ `残留标签页 ${alivePages} 个${connected ? ",浏览器进程仍处于连接状态" : ""}。` +
1317
+ `窗口可能仍在前台,需要用户手动关闭。${profile}。`,
1318
+ };
1319
+ },
1320
+ };
1321
+ return sess;
1322
+ }
1323
+ // ---------- engine-neutral session lifecycle ----------
1324
+ /** Idle reaper. The pre-v1.5.13 layer had NO cleanup path other than the
1325
+ * agent remembering to call `close` — and an agent that believes it
1326
+ * closed the window (issue #3) leaves the user staring at a Chromium
1327
+ * window nobody owns. An untouched session now closes itself and tells
1328
+ * the user it did. */
1329
+ let idleTimer = null;
1330
+ const armIdle = () => {
1331
+ if (idleTimer)
1332
+ clearTimeout(idleTimer);
1333
+ idleTimer = null;
1334
+ if (!idleCloseMs || !session)
1335
+ return;
1336
+ idleTimer = setTimeout(() => {
1337
+ idleTimer = null;
1338
+ if (!session)
1339
+ return;
1340
+ traj({ step_id: "browser", event: "idle_close", idle_ms: idleCloseMs });
1341
+ void closeActive()
1342
+ .then((r) => notify?.(`tm_browser 空闲 ${Math.round(idleCloseMs / 1000)}s,会话已自动关闭${r.closed ? "(窗口应已消失)" : "——但窗口可能仍需手动关闭"}`))
1343
+ .catch(() => { });
1344
+ }, idleCloseMs);
1345
+ idleTimer.unref?.();
1346
+ };
1347
+ async function ensureSession(headless) {
1348
+ if (session)
1349
+ return session;
1350
+ const sel = await selection();
1351
+ session =
1352
+ sel.kind === "playwright" && sel.pw
1353
+ ? await openPlaywrightSession(sel.pw, headless)
1354
+ : await openLegacySession(headless);
1355
+ return session;
358
1356
  }
1357
+ async function closeActive() {
1358
+ const s = session;
1359
+ if (!s)
1360
+ return { text: "没有打开的浏览器会话。", closed: true };
1361
+ session = null;
1362
+ if (idleTimer) {
1363
+ clearTimeout(idleTimer);
1364
+ idleTimer = null;
1365
+ }
1366
+ return s.close();
1367
+ }
1368
+ /** Move any screenshot pixels act() produced out of the session and onto
1369
+ * the ToolResult. Drained per call so a stale attachment can never ride
1370
+ * along on the next action. */
1371
+ function withAttachments(res, sess) {
1372
+ const atts = sess?.attachments ?? [];
1373
+ if (sess)
1374
+ sess.attachments = [];
1375
+ if (!atts.length)
1376
+ return res;
1377
+ const base = typeof res === "string" ? { output: res } : res;
1378
+ return { ...base, attachments: atts };
1379
+ }
1380
+ const ALL_ACTIONS = new Set([...BROWSER_PLAYWRIGHT_ACTIONS, ...BROWSER_COMPAT_ACTIONS, "navigate_page"]);
359
1381
  const execute = async (rawArgs, ctx) => {
360
1382
  try {
361
1383
  const args = rawArgs ?? {};
@@ -363,8 +1385,10 @@ export function buildTmBrowserTool(deps) {
363
1385
  const url = String(args.url ?? "").trim();
364
1386
  // allowlist FIRST — an out-of-allowlist URL only proceeds after the
365
1387
  // OFFICIAL dialog approves it (then the host passes the network layer
366
- // too); hard red lines (scheme / env-file) reject with no dialog
367
- if ((action === "open" || action === "navigate") && url) {
1388
+ // too); hard red lines (scheme / env-file) reject with no dialog.
1389
+ // NOTE: this gate runs BEFORE engine selection — a blocked target
1390
+ // never touches playwright nor spawns anything (§6o invariant).
1391
+ if ((action === "open" || action === "navigate" || action === "navigate_page") && url) {
368
1392
  const verdict = checkWebUrl(url, allowlist);
369
1393
  if (!verdict.ok) {
370
1394
  if (!verdict.askable || !verdict.url) {
@@ -386,81 +1410,114 @@ export function buildTmBrowserTool(deps) {
386
1410
  if (!url)
387
1411
  return toToolResult(tmError(tool, "args", "缺少 url 参数"));
388
1412
  if (session) {
389
- // already open: navigate instead of spawning a second browser
390
- return toToolResult(await navigateTo(url));
1413
+ // already open: navigate instead of spawning a second browser.
1414
+ // Report the mode the RUNNING instance is actually in — the old
1415
+ // code echoed the REQUESTED mode, so a sticky session could be
1416
+ // described as headful while it was a headless leftover.
1417
+ const out = await session.act("navigate", { url }, "browser");
1418
+ armIdle();
1419
+ return withAttachments(toToolResult(`复用已运行的会话(${session.headless ? "无头" : "有头窗口"} · ${session.label})。${out}`), session);
391
1420
  }
392
- const headless = args.headless != null ? Boolean(args.headless) : resolveHeadless(env);
1421
+ // Mode is an OPERATOR decision (TM_BROWSER_HEADLESS), never a model
1422
+ // arg: a model that once passed headless:"false" (string → truthy
1423
+ // under Boolean()) pinned the whole process to headless and every
1424
+ // anti-bot gate in the run failed.
1425
+ const headless = resolveHeadless(env);
393
1426
  const s = await ensureSession(headless);
394
- const out = await navigateTo(url);
395
- traj({ step_id: "browser", event: "open", headless });
396
- return toToolResult(`浏览器已启动(${headless ? "无头" : "有头窗口"},隔离临时配置)。${out}`);
1427
+ const out = await s.act("navigate", { url }, "browser");
1428
+ traj({ step_id: "browser", event: "open", headless, label: s.label });
1429
+ const mode = s.headless ? "无头" : "有头窗口";
1430
+ const profile = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim() ? "持久登录配置" : "隔离临时配置";
1431
+ const note = lastSel && preference !== "cdp-legacy" && lastSel.kind === "cdp-legacy"
1432
+ ? `\n(引擎:cdp-legacy 降级——${lastSel.reason ?? ""};完整 16 动作需 npm install playwright-core + node≥20)`
1433
+ : "";
1434
+ const guard = subPolicy === "off"
1435
+ ? `\n(子资源策略=off:跨域图片/脚本一律拦截,页面可能显示不全——TM_BROWSER_SUBRESOURCE=same-site 可恢复)`
1436
+ : "";
1437
+ armIdle();
1438
+ return withAttachments(toToolResult(`浏览器已启动(${mode},${profile} · ${s.label})。${out}${note}${guard}`), s);
397
1439
  }
398
- if (action === "navigate") {
1440
+ if (action === "navigate" || action === "navigate_page") {
399
1441
  if (!url)
400
1442
  return toToolResult(tmError(tool, "args", "缺少 url 参数"));
401
1443
  if (!session)
402
- return toToolResult(tmError(tool, "args", "没有打开的浏览器会话——先用 action:\"open\""));
403
- return toToolResult(await navigateTo(url));
404
- }
405
- if (action === "read") {
406
- if (!session)
407
- return toToolResult(tmError(tool, "args", "没有打开的浏览器会话——先用 action:\"open\""));
408
- const stepId = pipelines.nextStepId();
409
- traj({ step_id: stepId, event: "call", url: session.currentUrl.slice(0, 200) });
410
- const ev = await session.cdp.call("Runtime.evaluate", { expression: "document.body ? document.body.innerText : ''", returnByValue: true }, session.sessionId);
411
- const text = String(ev.result?.value ?? "");
412
- traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
413
- return toToolResult(`页面文本(${session.currentUrl}):\n${text.slice(0, 20000)}${text.length > 20000 ? "\n…(截断)" : ""}`);
414
- }
415
- if (action === "screenshot") {
416
- if (!session)
417
- return toToolResult(tmError(tool, "args", "没有打开的浏览器会话——先用 action:\"open\""));
418
- const stepId = pipelines.nextStepId();
419
- traj({ step_id: stepId, event: "call", kind: "screenshot", url: session.currentUrl.slice(0, 200) });
420
- const shot = await session.cdp.call("Page.captureScreenshot", { format: "png" }, session.sessionId);
421
- const data = String(shot.data ?? "");
422
- const png = Buffer.from(data, "base64");
423
- const dir = path.join(pipelines.store.stepsRoot(), stepId);
424
- fs.mkdirSync(dir, { recursive: true });
425
- const file = path.join(dir, "screenshot.png");
426
- fs.writeFileSync(file, png);
427
- traj({ step_id: stepId, event: "result", bytes: png.length });
428
- return toToolResult(`截图已保存(${png.length} bytes):${file}\n(PNG 已落 run store;上下文只携带路径,不携带像素。)`);
1444
+ return toToolResult(tmError(tool, "args", '没有打开的浏览器会话——先用 action:"open"'));
1445
+ const out = await session.act(action, { url }, "browser");
1446
+ armIdle();
1447
+ return withAttachments(toToolResult(out), session);
429
1448
  }
430
1449
  if (action === "close") {
431
1450
  traj({ step_id: "browser", event: "close" });
432
- return toToolResult(await closeSession());
1451
+ const r = await closeActive();
1452
+ return toToolResult(r.text);
433
1453
  }
434
- return toToolResult(tmError(tool, "args", `未知 action "${String(action).slice(0, 30)}"——可用: open | navigate | read | screenshot | close`));
1454
+ if (!ALL_ACTIONS.has(action)) {
1455
+ return toToolResult(tmError(tool, "args", `未知 action "${String(action).slice(0, 30)}"——可用: open | navigate | take_snapshot | click | fill | hover | drag | press_key | select_page | upload_file | wait_for | evaluate_script | list_console_messages | list_network_requests | list_pages | take_screenshot | handle_dialog | read | screenshot | close(快照优先:先 take_snapshot,按 [uid=…] 寻址)`));
1456
+ }
1457
+ if (!session)
1458
+ return toToolResult(tmError(tool, "args", '没有打开的浏览器会话——先用 action:"open"'));
1459
+ // M1: upload_file SOURCE containment runs at the execute layer — the
1460
+ // engine act() has no host ctx. Out-of-P2 paths and R6 env files are
1461
+ // refused before setInputFiles can move any bytes.
1462
+ if (action === "upload_file") {
1463
+ const refusal = uploadExfilGuard(args, ctx);
1464
+ if (refusal) {
1465
+ traj({ step_id: "browser", event: "upload_refused", reason: refusal.slice(0, 200) });
1466
+ return toToolResult(tmError(tool, "permission", refusal));
1467
+ }
1468
+ }
1469
+ const stepId = pipelines.nextStepId();
1470
+ const out = await session.act(action, args, stepId);
1471
+ armIdle();
1472
+ return withAttachments(toToolResult(out), session);
435
1473
  }
436
1474
  catch (err) {
437
1475
  const e = err;
438
1476
  return toToolResult(tmError(tool, "execute", String(e?.message ?? err ?? "browser 操作失败")));
439
1477
  }
440
1478
  };
441
- async function navigateTo(url) {
442
- const s = session;
443
- await s.cdp.call("Page.navigate", { url }, s.sessionId);
444
- await s.cdp.waitEvent("Page.loadEventFired", s.sessionId, NAVIGATE_EVENT_TIMEOUT_MS).catch(() => { });
445
- s.currentUrl = url;
446
- return `已导航:${url}`;
447
- }
448
- const DESCRIPTION = `Interactive browser (governed, Plan C): drives the user's own Chromium-family browser HEADFUL via CDP pipe — a visible window opens on desktops; display-less Linux hosts automatically run headless (TM_BROWSER_HEADLESS=1|0 forces either way). Actions:
449
- - open: { url } — launch (or reuse) the session and navigate. Uses your DEFAULT browser (Chromium-family; TM_BROWSER_PATH overrides; falls back to Edge/Chrome probes when the default is Firefox — CDP is Chromium-only). Isolated temp profile (never your real profile); per-request DOMAIN ALLOWLIST enforced at the network layer (seeded = tm_webfetch's hosts; extend via TM_WEBFETCH_ALLOWED_DOMAINS).
450
- - navigate: { url } · read: extract page text (threshold-governed like tm_read) · screenshot: save PNG into the run store, only the path enters context · close: kill + cleanup.
451
- - Fixed priority ladder: ① tm_* governed tools → ② user MCP/plugin tools → ③ reasoning (never fabricate). If no browser is installed (TM_BROWSER_PATH override exists), a structured error tells you to fall back to tm_webfetch / MCP.
452
- - Network roles: team + researcher full grant, tester browser-only (UI verification). Out-of-allowlist open/navigate routes through the OFFICIAL confirmation dialog (approved hosts pass the network layer for the session); env-file URLs and non-http(s) schemes hard-reject.`;
1479
+ const DESCRIPTION = `Interactive browser (governed, Plan C): drives the user's own Chromium-family browser HEADFUL — playwright-core engine primary (npm install + node>=20; auto-degrades to the zero-dep CDP pipe when absent, snapshot actions then unavailable). Snapshot-first flow: open → take_snapshot → act by [uid] → observe again.
1480
+ - 16 actions (chrome-devtools-mcp aligned): navigate_page { url } · take_snapshot { } → ariaSnapshot YAML with injected [uid=eN] · click/fill{text}/hover { uid|selector } · drag { uid, targetUid } · press_key { key } · select_page { index } · upload_file { uid, filePath } (filePath must live INSIDE the workspace/blackboard scope — .env & shell-rc files are refused) · wait_for { text|uid, timeoutMs<=3000 default } · evaluate_script { function } · list_console_messages · list_network_requests · list_pages · take_screenshot { fullPage?, image? } → the PNG always lands in the run store (path in the reply); with image:true a quality-70 JPEG of the same view is attached to THIS result so a vision model can actually see it (opt-in: pixels cost context, so ask only when the screenshot is the evidence) · handle_dialog { dialogAction: accept|dismiss, promptText }.
1481
+ - Compat actions: open { url } — launch/reuse + navigate (TM_BROWSER_PATH override → DEFAULT browser, Chromium-family only; the registry ProgId decides the CHANNEL, so an Edge Beta default opens Edge Beta, not stable). Isolated temp profile by default; persistent login ONLY via TM_BROWSER_USER_DATA_DIR (the real profile is never touched). navigate / read (page text) / screenshot / close also work.
1482
+ - Lifecycle (the user SEES this window): headful by default — headless is an operator setting (TM_BROWSER_HEADLESS), not a parameter you can pass. An idle session closes itself (TM_BROWSER_IDLE_MS, default 180s) and the user is told. ALWAYS action:"close" when your browser work is done, and quote the tool's own close line — "已确认关闭" vs "警告:关闭未完全成功" — instead of asserting the window is gone.
1483
+ - Discipline (enforced by defaults): act ONLY on uids from the latest take_snapshot — no guessed locators; one action then one observation; fold dialogs into the same round (snapshot header warns while a dialog is held); 3000 ms action budget; networkidle is never waited on; screenshots are the visual-last-resort, not the primary read.
1484
+ - Network: the top-level navigation must clear the domain allowlist (seeded = tm_webfetch's hosts; out-of-allowlist open/navigate asks the user through the OFFICIAL confirmation dialog BEFORE any spawn). SUBRESOURCES then follow TM_BROWSER_SUBRESOURCE (default same-site): images/media/fonts/stylesheets load, a script/XHR loads when it belongs to a site this session actually opened, anything else is blocked and reported as a "N 个子资源被拦截" note on the next snapshot — that note means the gate trimmed the page, NOT that the site has no images. env-file URLs and non-http(s) schemes hard-reject under every policy.
1485
+ - Fixed priority ladder: ① tm_* governed tools → ② user MCP/plugin tools → ③ reasoning (never fabricate). No browser installed → structured error, fall back to tm_webfetch / MCP. Role grant: team + researcher full, tester browser-only (UI verification).`;
453
1486
  return {
454
1487
  description: DESCRIPTION,
455
1488
  args: deps.args ?? {
456
- action: { descriptor: "action: open|navigate|read|screenshot|close (required)" },
457
- url: { descriptor: "url: string (open/navigate, allowlisted https)" },
458
- headless: { descriptor: "headless: boolean (open, optional — default auto)" },
1489
+ action: { descriptor: "action: take_snapshot|click|fill|navigate_page|... (16 playwright verbs + open/navigate/read/screenshot/close)" },
1490
+ url: { descriptor: "url: string (open/navigate/navigate_page, allowlisted https)" },
1491
+ image: { descriptor: "image: true (take_screenshot only — attach the PNG pixels to this result; default is path-only)" },
1492
+ uid: { descriptor: "uid: snapshot [uid=eN] token (click/fill/hover/drag/upload_file/wait_for)" },
1493
+ selector: { descriptor: "selector: CSS/text locator escape hatch (only when a snapshot cannot express the node)" },
1494
+ targetUid: { descriptor: "targetUid: drag destination uid" },
1495
+ targetSelector: { descriptor: "targetSelector: drag destination selector" },
1496
+ text: { descriptor: "text: fill value / wait_for visible text" },
1497
+ key: { descriptor: "key: press_key chord, e.g. Enter|Control+A" },
1498
+ function: { descriptor: "function: JS function source for evaluate_script" },
1499
+ filePath: { descriptor: "filePath: local file path(s) for upload_file" },
1500
+ index: { descriptor: "index: select_page target from list_pages" },
1501
+ timeoutMs: { descriptor: "timeoutMs: wait_for budget (default 3000)" },
1502
+ fullPage: { descriptor: "fullPage: take_screenshot full page (default viewport only)" },
1503
+ dialogAction: { descriptor: "dialogAction: accept|dismiss for handle_dialog (default accept)" },
1504
+ promptText: { descriptor: "promptText: prompt-dialog reply for handle_dialog accept" },
1505
+ clear: { descriptor: "clear: drain the console buffer after list_console_messages" },
459
1506
  },
460
1507
  execute,
461
- dispose: () => {
462
- void closeSession().catch(() => { });
1508
+ dispose: async () => {
1509
+ if (idleTimer) {
1510
+ clearTimeout(idleTimer);
1511
+ idleTimer = null;
1512
+ }
1513
+ // AWAITED: the host's dispose hook is Promise-returning, and tearing
1514
+ // down while a close was still in flight left the window on screen.
1515
+ await closeActive().catch(() => { });
463
1516
  },
1517
+ engineInfo: () => ({
1518
+ kind: lastSel?.kind ?? null,
1519
+ reason: lastSel?.reason ?? null,
1520
+ }),
464
1521
  };
465
1522
  }
466
1523
  //# sourceMappingURL=browser.js.map