@te-river/opencode-team-mode 1.5.12 → 1.5.14

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 (88) hide show
  1. package/README.md +30 -6
  2. package/README.zh-CN.md +28 -4
  3. package/dist/agents.d.ts +5 -1
  4. package/dist/agents.d.ts.map +1 -1
  5. package/dist/agents.js +24 -2
  6. package/dist/agents.js.map +1 -1
  7. package/dist/capabilities.d.ts +66 -0
  8. package/dist/capabilities.d.ts.map +1 -0
  9. package/dist/capabilities.js +165 -0
  10. package/dist/capabilities.js.map +1 -0
  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 +103 -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 +71 -8
  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 +51 -2
  25. package/dist/prompts/shared.js.map +1 -1
  26. package/dist/prompts/specialists.d.ts +1 -1
  27. package/dist/prompts/specialists.d.ts.map +1 -1
  28. package/dist/prompts/specialists.js +38 -17
  29. package/dist/prompts/specialists.js.map +1 -1
  30. package/dist/tm/args-schema.d.ts +7 -1
  31. package/dist/tm/args-schema.d.ts.map +1 -1
  32. package/dist/tm/args-schema.js +9 -3
  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 +91 -5
  39. package/dist/tm/browser.d.ts.map +1 -1
  40. package/dist/tm/browser.js +564 -89
  41. package/dist/tm/browser.js.map +1 -1
  42. package/dist/tm/cache.d.ts +68 -0
  43. package/dist/tm/cache.d.ts.map +1 -0
  44. package/dist/tm/cache.js +157 -0
  45. package/dist/tm/cache.js.map +1 -0
  46. package/dist/tm/config.d.ts +87 -0
  47. package/dist/tm/config.d.ts.map +1 -1
  48. package/dist/tm/config.js +66 -0
  49. package/dist/tm/config.js.map +1 -1
  50. package/dist/tm/dispatch.d.ts +123 -0
  51. package/dist/tm/dispatch.d.ts.map +1 -0
  52. package/dist/tm/dispatch.js +476 -0
  53. package/dist/tm/dispatch.js.map +1 -0
  54. package/dist/tm/dupe-guard.d.ts +58 -0
  55. package/dist/tm/dupe-guard.d.ts.map +1 -0
  56. package/dist/tm/dupe-guard.js +105 -0
  57. package/dist/tm/dupe-guard.js.map +1 -0
  58. package/dist/tm/index.d.ts +32 -4
  59. package/dist/tm/index.d.ts.map +1 -1
  60. package/dist/tm/index.js +58 -5
  61. package/dist/tm/index.js.map +1 -1
  62. package/dist/tm/perm-ask.d.ts +1 -0
  63. package/dist/tm/perm-ask.d.ts.map +1 -1
  64. package/dist/tm/perm-ask.js +15 -1
  65. package/dist/tm/perm-ask.js.map +1 -1
  66. package/dist/tm/pipelines.d.ts.map +1 -1
  67. package/dist/tm/pipelines.js +11 -0
  68. package/dist/tm/pipelines.js.map +1 -1
  69. package/dist/tm/pty.d.ts +70 -0
  70. package/dist/tm/pty.d.ts.map +1 -0
  71. package/dist/tm/pty.js +242 -0
  72. package/dist/tm/pty.js.map +1 -0
  73. package/dist/tm/search.d.ts +56 -7
  74. package/dist/tm/search.d.ts.map +1 -1
  75. package/dist/tm/search.js +195 -41
  76. package/dist/tm/search.js.map +1 -1
  77. package/dist/tm/stats.d.ts +117 -0
  78. package/dist/tm/stats.d.ts.map +1 -0
  79. package/dist/tm/stats.js +296 -0
  80. package/dist/tm/stats.js.map +1 -0
  81. package/dist/tm/webfetch.d.ts +23 -0
  82. package/dist/tm/webfetch.d.ts.map +1 -1
  83. package/dist/tm/webfetch.js +97 -8
  84. package/dist/tm/webfetch.js.map +1 -1
  85. package/dist/types.d.ts +16 -0
  86. package/dist/types.d.ts.map +1 -1
  87. package/docs/installation.md +216 -17
  88. package/package.json +2 -2
@@ -30,16 +30,21 @@
30
30
  * Hardening (unchanged from the CDP era, ported to both engines):
31
31
  * - isolated temp profile (never the user's real browser profile);
32
32
  * persistent login is ONLY via an explicit TM_BROWSER_USER_DATA_DIR;
33
- * - domain allowlist enforced at the NETWORK layer: playwright
34
- * context.route() abort / CDP Fetch.requestPaused fail — every request
35
- * and every redirect hop re-checked (per-hop analog);
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;
36
40
  * - out-of-allowlist open/navigate routes through the OFFICIAL dialog
37
41
  * BEFORE any spawn; approved hosts also pass the network layer;
38
42
  * - hardened context: real-Chrome UA + zh-CN Accept-Language,
39
43
  * no-first-run / no-extensions / mute launch flags, 3 s default action
40
44
  * timeout (the prompt's "3000 ms budget" is the tool's default, not an
41
45
  * aspiration), no networkidle waits anywhere;
42
- * - dispose kills the child / closes the browser.
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.
43
48
  *
44
49
  * Environment adaptivity (different OpenCode hosts):
45
50
  * - headful by default (Plan C); display-less Linux (no DISPLAY/
@@ -67,23 +72,42 @@ import { isEnvFilePath } from "../envprotect.js";
67
72
  import { tmError, toToolResult } from "./result.js";
68
73
  import { estimateTokens } from "./config.js";
69
74
  import { rmForceSafe } from "../fs-safe.js";
70
- /** 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). */
71
81
  const BROWSER_CANDIDATES = {
72
82
  win32: [
73
83
  "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
74
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",
75
91
  "%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe",
76
92
  "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
77
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",
78
97
  ],
79
98
  darwin: [
80
99
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
81
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",
82
104
  "/Applications/Chromium.app/Contents/MacOS/Chromium",
83
105
  ],
84
106
  linux: [
85
107
  "/usr/bin/google-chrome",
86
108
  "/usr/bin/google-chrome-stable",
109
+ "/usr/bin/google-chrome-beta",
110
+ "/usr/bin/google-chrome-unstable",
87
111
  "/usr/bin/chromium-browser",
88
112
  "/usr/bin/chromium",
89
113
  "/snap/bin/chromium",
@@ -113,24 +137,84 @@ export function parseDesktopExec(text) {
113
137
  const m = /^Exec\s*=\s*(?:"([^"]+)"|(\S+))/m.exec(String(text ?? ""));
114
138
  return m ? (m[1] ?? m[2]) : null;
115
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
+ }
116
179
  /** The user's DEFAULT browser, when it is Chromium-family and present.
117
180
  * null → the caller falls back to the per-OS probe list (detection is
118
181
  * best-effort and must never throw into tool discovery). */
119
182
  export function defaultBrowserExecutable(env = process.env, run = defaultRunner) {
120
183
  try {
121
184
  if (process.platform === "win32") {
122
- const choice = run("reg", [
123
- "query",
124
- "HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice",
125
- "/v",
126
- "ProgId",
127
- ]);
128
- const progId = parseProgId(choice);
129
- if (!progId)
130
- return null;
131
- const cmd = run("reg", ["query", `HKCU\\Software\\Classes\\${progId}\\shell\\open\\command`, "/ve"]);
132
- const exe = parseRegCommand(cmd);
133
- 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;
134
218
  }
135
219
  if (process.platform === "linux") {
136
220
  const desk = run("xdg-settings", ["get", "default-web-browser"]).trim();
@@ -157,6 +241,23 @@ export function defaultBrowserExecutable(env = process.env, run = defaultRunner)
157
241
  }
158
242
  return null;
159
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
+ }
160
261
  /** Resolve the browser executable: TM_BROWSER_PATH override → the user's
161
262
  * DEFAULT browser (Chromium-family only) → per-OS candidate paths.
162
263
  * %LOCALAPPDATA% style vars are expanded on win32. */
@@ -200,6 +301,133 @@ export function resolveHeadless(env = process.env) {
200
301
  return !env.DISPLAY && !env.WAYLAND_DISPLAY;
201
302
  return false;
202
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
+ }
381
+ // ---------- evaluate_script: consent + result redaction ---------------------
382
+ /** `evaluate_script` is the one browser verb the network gate cannot cover:
383
+ * the allowlist limits where we NAVIGATE, not what an already-loaded page
384
+ * hands back. In a `TM_BROWSER_USER_DATA_DIR` profile that page may be a
385
+ * signed-in session, and `document.cookie` / `localStorage` / a token in the
386
+ * DOM is one expression away — and its value would then ride into the model
387
+ * context, the run store and the trajectory. So: ask once per browser
388
+ * session (the OFFICIAL dialog), and name-anchor the result on the shapes
389
+ * that are secrets, not on "looks random" (which would blank legitimate
390
+ * JSON and teach the agent to distrust every read). */
391
+ export const EVAL_SECRET_SHAPES = [
392
+ { name: "JWT", re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
393
+ { name: "bearer", re: /\b(bearer\s+[A-Za-z0-9._~+/-]{12,}=*)/gi },
394
+ { name: "auth-header", re: /\b(authorization\s*[:=]\s*[^\s"',}]{8,})/gi },
395
+ { name: "cookie", re: /\b(set-cookie|cookie)\s*[:=]\s*[^"',}\n]{6,}/gi },
396
+ { name: "api-key", re: /\b(sk|ghp|gho|github_pat|xox[baprs]|AKIA|AIza)[A-Za-z0-9_-]{8,}\b/g },
397
+ { name: "secret-pair", re: /\b((?:password|passwd|secret|api_?key|access_?token|refresh_?token|client_?secret)\s*[:=]\s*"?[^\s"',}]{4,})/gi },
398
+ ];
399
+ /** Mask every known secret shape in an in-page result. Returns the masked
400
+ * text plus WHICH kinds were masked (counted, never the values). */
401
+ export function redactEvalResult(text) {
402
+ let out = text;
403
+ const kinds = [];
404
+ for (const shape of EVAL_SECRET_SHAPES) {
405
+ const re = new RegExp(shape.re.source, shape.re.flags);
406
+ let hits = 0;
407
+ out = out.replace(re, () => {
408
+ hits++;
409
+ return `〔已脱敏:${shape.name}〕`;
410
+ });
411
+ if (hits)
412
+ kinds.push(`${shape.name}×${hits}`);
413
+ }
414
+ return { text: out, masked: kinds };
415
+ }
416
+ /** Consent is per BROWSER SESSION, not per call: one dialog when the lead
417
+ * decides to script the page, then the round stops paying for it. */
418
+ export function needsEvalConsent(policy, alreadyApproved) {
419
+ return policy !== "off" && !alreadyApproved;
420
+ }
421
+ /** Host only — the consent line in the dialog and in the trajectory never
422
+ * carries a query string (a search URL can hold the very token we guard). */
423
+ export function hostOnly(url) {
424
+ try {
425
+ return new URL(url).host;
426
+ }
427
+ catch {
428
+ return url.slice(0, 60);
429
+ }
430
+ }
203
431
  class PipeCdp {
204
432
  child;
205
433
  nextId = 1;
@@ -425,6 +653,7 @@ const NAVIGATE_EVENT_TIMEOUT_MS = 15_000;
425
653
  export function buildTmBrowserTool(deps) {
426
654
  const { pipelines } = deps;
427
655
  const env = deps.env ?? process.env;
656
+ const notify = typeof deps.notify === "function" ? deps.notify : undefined;
428
657
  const tool = "tm_browser";
429
658
  const traj = (e) => pipelines.store.appendTrajectory({ tool, ...e });
430
659
  let session = null;
@@ -437,6 +666,11 @@ export function buildTmBrowserTool(deps) {
437
666
  // network layer (both engines) consults this in addition to the static allowlist
438
667
  const approvedHosts = new Set();
439
668
  const snapshotBudget = Math.max(10, Number(deps.cfg?.browserSnapshotMaxTokens) || 1200);
669
+ const subPolicy = deps.cfg?.browserSubresource ?? "same-site";
670
+ const evalAskPolicy = deps.cfg?.browserAskEval ?? "on";
671
+ const discover = deps.findExecutable ?? findBrowserExecutable;
672
+ const imageMaxBytes = Math.max(1000, Number(deps.cfg?.browserImageMaxBytes) || 400_000);
673
+ const idleCloseMs = Number(deps.cfg?.browserIdleCloseMs ?? 180_000);
440
674
  // ---------- engine selection (cached per plugin instance) ----------
441
675
  let selPromise = null;
442
676
  let lastSel = null;
@@ -479,8 +713,71 @@ export function buildTmBrowserTool(deps) {
479
713
  traj({ step_id: stepId, event: "result", bytes: png.length });
480
714
  return { file, png };
481
715
  }
482
- function shotOutput(file, bytes) {
483
- return `截图已保存(${bytes} bytes):${file}\n(PNG 已落 run store;上下文只携带路径,不携带像素。)`;
716
+ /** LLM args are booleans most of the time, but `"false"` / `"0"` strings
717
+ * are common — a bare `Boolean()` read both as TRUE, which is exactly how
718
+ * a model used to force tm_browser into headless (and get anti-bot
719
+ * blocked) on a desktop that defaults to headful. */
720
+ function isTrueArg(raw) {
721
+ if (raw === true)
722
+ return true;
723
+ if (typeof raw === "number")
724
+ return raw === 1;
725
+ if (typeof raw === "string")
726
+ return /^(1|true|yes|on)$/i.test(raw.trim());
727
+ return false;
728
+ }
729
+ /** Screenshot line + the OPT-IN pixels.
730
+ * The PNG always lands in the run store (it is the evidence artifact);
731
+ * what rides back to the MODEL is a quality-70 JPEG — a real page with
732
+ * images loaded is ~1.5 MB as PNG, which no token budget should ever
733
+ * inline, and ~150-300 KB as JPEG. Pixels stay opt-in so a UI sweep
734
+ * cannot spend context the agent never asked for. */
735
+ async function shotOutput(sess, file, png, args, captureJpeg) {
736
+ const head = `截图已保存(PNG ${png.length} bytes):${file}`;
737
+ if (!isTrueArg(args.image)) {
738
+ return `${head}\n(上下文只携带路径,不携带像素——需要真正看到画面时带 image:true 再截一次。)`;
739
+ }
740
+ let img = null;
741
+ if (captureJpeg) {
742
+ try {
743
+ img = await captureJpeg();
744
+ }
745
+ catch {
746
+ img = null;
747
+ }
748
+ }
749
+ let kind = "jpeg";
750
+ if (!img) {
751
+ img = png;
752
+ kind = "png";
753
+ }
754
+ if (img.length > imageMaxBytes) {
755
+ return `${head}\n(未附带像素:${kind.toUpperCase()} ${img.length} bytes 超过 TM_BROWSER_IMAGE_MAX_BYTES=${imageMaxBytes}。改用视口截图(去掉 fullPage)或提高该上限。)`;
756
+ }
757
+ sess.attachments.push({
758
+ type: "file",
759
+ mime: `image/${kind}`,
760
+ url: `data:image/${kind};base64,${img.toString("base64")}`,
761
+ filename: path.basename(file).replace(/\.png$/, `.${kind === "jpeg" ? "jpg" : "png"}`),
762
+ });
763
+ return `${head}\n(像素以 ${kind.toUpperCase()} ${img.length} bytes 随本条结果附带。)`;
764
+ }
765
+ /** One line telling the agent that the page is NOT actually empty — the
766
+ * subresource gate dropped N requests. Without this the model reads a
767
+ * blank-looking page and reports "该网站没有图片". */
768
+ function blockedNote(sess) {
769
+ if (!sess.blocked.count)
770
+ return "";
771
+ const n = sess.blocked.count;
772
+ const hosts = [...sess.blocked.hosts];
773
+ // RESET IN PLACE — the engine's route handler holds this very object, so
774
+ // swapping in a new one would silently stop counting after the first
775
+ // drain (and the note would never appear again for the session).
776
+ sess.blocked.count = 0;
777
+ sess.blocked.hosts.clear();
778
+ traj({ step_id: "browser", event: "blocked", count: n, hosts: hosts.slice(0, 20).join(",") });
779
+ return (`\n注意:本页有 ${n} 个子资源请求被治理白名单拦截(${hosts.slice(0, 6).join(", ")}${hosts.length > 6 ? ` …+${hosts.length - 6}` : ""})——不是站点没有内容。` +
780
+ `当前策略 TM_BROWSER_SUBRESOURCE=${subPolicy};同域资源已自动放行,跨域脚本仍需白名单或弹窗批准。`);
484
781
  }
485
782
  function pageTextOutput(url, text) {
486
783
  return `页面文本(${url}):\n${text.slice(0, 20000)}${text.length > 20000 ? "\n…(截断)" : ""}`;
@@ -517,8 +814,52 @@ export function buildTmBrowserTool(deps) {
517
814
  return null;
518
815
  }
519
816
  // ================= cdp-legacy engine (原手写 CDP 管道,保留不删) =============
817
+ /**
818
+ * Shared network verdict for BOTH engines — one implementation, so the
819
+ * playwright and cdp-legacy legs can never disagree about what passes.
820
+ * The hard red lines (non-http scheme, remote env-file spelling) stay hard
821
+ * under EVERY subresource policy: they are not consentable, so a passive
822
+ * image or a same-site script can never relax them.
823
+ */
824
+ function gateDecide(st, url, resourceType) {
825
+ const verdict = checkWebUrl(url, allowlist);
826
+ if (!verdict.ok && !verdict.askable)
827
+ return { pass: false, via: "red-line" };
828
+ let approved = false;
829
+ try {
830
+ approved = approvedHosts.has(new URL(url).hostname);
831
+ }
832
+ catch {
833
+ approved = false;
834
+ }
835
+ const v = subresourcePass({
836
+ url,
837
+ resourceType,
838
+ policy: subPolicy,
839
+ allowlistHit: verdict.ok,
840
+ approvedHost: approved,
841
+ allowedSites: st.allowedSites,
842
+ });
843
+ if (v.pass) {
844
+ if (normalizeResourceType(resourceType) === "document")
845
+ rememberSite(st.allowedSites, url);
846
+ }
847
+ else {
848
+ st.blocked.count++;
849
+ try {
850
+ st.blocked.hosts.add(new URL(url).hostname);
851
+ }
852
+ catch {
853
+ /* no host to name */
854
+ }
855
+ }
856
+ // NO per-request trajectory: a real page is hundreds of requests and the
857
+ // log would be pure noise. Blocked verdicts aggregate into st.blocked
858
+ // and flush as ONE line (blockedNote / close).
859
+ return v;
860
+ }
520
861
  async function openLegacySession(headless) {
521
- const executable = findBrowserExecutable(env);
862
+ const executable = discover(env);
522
863
  if (!executable)
523
864
  throw discoveryError();
524
865
  const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-browser-"));
@@ -557,56 +898,58 @@ export function buildTmBrowserTool(deps) {
557
898
  throw new Error("浏览器启动后未找到 page target");
558
899
  const { sessionId } = (await cdp.call("Target.attachToTarget", { targetId: page.targetId, flatten: true }));
559
900
  await cdp.call("Page.enable", {}, sessionId);
560
- // network-layer allowlist enforcement (Qoder's per-hop re-check analog)
901
+ // network-layer enforcement — the SAME gateDecide the playwright leg uses
902
+ // (subresource policy + per-hop redirect re-check + dialog-approved hosts)
903
+ const allowedSites = new Set();
904
+ const blocked = { count: 0, hosts: new Set() };
561
905
  await cdp.call("Fetch.enable", { patterns: [{ urlPattern: "*" }] }, sessionId);
562
906
  cdp.onEvent((m) => {
563
907
  if (m.method !== "Fetch.requestPaused" || m.sessionId !== sessionId)
564
908
  return;
565
909
  const requestId = String(m.params.requestId ?? "");
566
- const url = String(m.params.request?.url ?? "");
567
- const verdict = checkWebUrl(url, allowlist);
568
- let pass = verdict.ok;
569
- if (!pass) {
570
- // an approved-via-dialog host passes the network layer too
571
- try {
572
- pass = approvedHosts.has(new URL(url).hostname);
573
- }
574
- catch {
575
- pass = false;
576
- }
577
- }
910
+ const p = m.params.request ?? {};
911
+ const url = String(p.url ?? "");
912
+ const { pass } = gateDecide({ allowedSites, blocked }, url, p.resourceType);
578
913
  if (pass) {
579
914
  void cdp.call("Fetch.continueRequest", { requestId }, sessionId, 5000).catch(() => { });
580
915
  }
581
916
  else {
582
- traj({ step_id: "browser", event: "blocked", url: url.slice(0, 200) });
583
917
  void cdp.call("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }, sessionId, 5000).catch(() => { });
584
918
  }
585
919
  });
586
920
  const sess = {
587
921
  kind: "cdp-legacy",
588
922
  currentUrl: "about:blank",
923
+ headless,
924
+ label: `cdp-legacy · ${path.basename(executable)} · pid ${child.pid ?? "?"}`,
925
+ attachments: [],
926
+ blocked,
927
+ allowedSites,
589
928
  async act(action, args, stepId) {
590
929
  if (action === "navigate" || action === "navigate_page") {
591
930
  const url = String(args.url ?? "").trim();
931
+ rememberSite(allowedSites, url);
592
932
  await cdp.call("Page.navigate", { url }, sessionId);
593
933
  await cdp.waitEvent("Page.loadEventFired", sessionId, NAVIGATE_EVENT_TIMEOUT_MS).catch(() => { });
594
934
  sess.currentUrl = url;
595
- return `已导航:${url}`;
935
+ return `已导航:${url}${blockedNote(sess)}`;
596
936
  }
597
937
  if (action === "read") {
598
938
  traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
599
939
  const ev = await cdp.call("Runtime.evaluate", { expression: "document.body ? document.body.innerText : ''", returnByValue: true }, sessionId);
600
940
  const text = String(ev.result?.value ?? "");
601
941
  traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
602
- return pageTextOutput(sess.currentUrl, text);
942
+ return `${pageTextOutput(sess.currentUrl, text)}${blockedNote(sess)}`;
603
943
  }
604
944
  if (action === "screenshot" || action === "take_screenshot") {
605
945
  traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
606
946
  const shot = await cdp.call("Page.captureScreenshot", { format: "png" }, sessionId);
607
947
  const png = Buffer.from(String(shot.data ?? ""), "base64");
608
948
  const { file } = saveShotPng(new Uint8Array(png), stepId);
609
- return shotOutput(file, png.length);
949
+ return shotOutput(sess, file, png, args, async () => {
950
+ const j = await cdp.call("Page.captureScreenshot", { format: "jpeg", quality: 70 }, sessionId);
951
+ return Buffer.from(String(j.data ?? ""), "base64");
952
+ });
610
953
  }
611
954
  if (action === "list_pages") {
612
955
  const t = await cdp.call("Target.getTargets");
@@ -643,13 +986,20 @@ export function buildTmBrowserTool(deps) {
643
986
  res();
644
987
  });
645
988
  });
989
+ const dead = child.exitCode !== null;
646
990
  try {
647
991
  rmForceSafe(profileDir, { recursive: true });
648
992
  }
649
993
  catch {
650
994
  /* a locked leftover temp dir is reclaimed by the OS — never fail close */
651
995
  }
652
- return "浏览器会话已关闭,临时配置目录已清理。";
996
+ return dead
997
+ ? { closed: true, text: "浏览器进程已退出(cdp-legacy),临时配置目录已清理。" }
998
+ : {
999
+ closed: false,
1000
+ text: `警告:cdp-legacy 子进程 pid ${child.pid ?? "?"} 未在 3s 内退出,窗口可能仍在前台——` +
1001
+ `请手动关闭该浏览器窗口(临时目录 ${profileDir})。`,
1002
+ };
653
1003
  },
654
1004
  };
655
1005
  const onExit = () => {
@@ -669,7 +1019,7 @@ export function buildTmBrowserTool(deps) {
669
1019
  }
670
1020
  // ================= playwright engine (primary) ==============================
671
1021
  async function openPlaywrightSession(pw, headless) {
672
- const executable = findBrowserExecutable(env);
1022
+ const executable = discover(env);
673
1023
  if (!executable)
674
1024
  throw discoveryError();
675
1025
  const launchArgs = [
@@ -687,32 +1037,35 @@ export function buildTmBrowserTool(deps) {
687
1037
  viewport: headless ? undefined : null,
688
1038
  };
689
1039
  const persistentDir = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim();
690
- // R2: channel launches are cross-version-fragile (official warning), so
691
- // they are a SMOKE-ONLY attempt; any failure retries with the explicit
692
- // executablePath from our own discovery layer.
693
- const channel = /msedge/i.test(executable) ? "msedge" : /chrome/i.test(executable) ? "chrome" : undefined;
1040
+ // R2 (revised 2026-09-18): playwright's `channel` resolves the executable
1041
+ // ITSELF, which silently overrode our discovery (a Beta-default Windows
1042
+ // host opened STABLE Edge). executablePath is now the primary launch and
1043
+ // the channel spelling only a fallback — the opposite of the old order.
1044
+ const target = playwrightLaunchTarget(executable, {
1045
+ explicitOverride: String(env.TM_BROWSER_PATH ?? "").trim() !== "",
1046
+ });
694
1047
  let browser = null;
695
1048
  let context;
696
1049
  let via = "executablePath";
697
1050
  if (persistentDir) {
698
1051
  context = await pw.chromium.launchPersistentContext(persistentDir, {
699
1052
  ...contextOpts,
700
- ...(channel ? { channel } : { executablePath: executable }),
1053
+ executablePath: target.executablePath,
701
1054
  headless,
702
1055
  args: launchArgs,
703
1056
  });
704
- via = `persistent(${channel ?? "executablePath"})`;
1057
+ via = `persistent(executablePath)`;
705
1058
  }
706
1059
  else {
707
1060
  try {
708
- browser = await pw.chromium.launch({ ...(channel ? { channel } : { executablePath: executable }), headless, args: launchArgs });
709
- via = channel ? `channel(${channel})` : "executablePath";
1061
+ browser = await pw.chromium.launch({ executablePath: target.executablePath, headless, args: launchArgs });
1062
+ via = "executablePath";
710
1063
  }
711
1064
  catch (e1) {
712
- if (!channel)
1065
+ if (!target.channel)
713
1066
  throw e1;
714
- browser = await pw.chromium.launch({ executablePath: executable, headless, args: launchArgs });
715
- via = "executablePath(channel 回退)";
1067
+ browser = await pw.chromium.launch({ channel: target.channel, headless, args: launchArgs });
1068
+ via = `channel(${target.channel})`;
716
1069
  }
717
1070
  context = await browser.newContext(contextOpts);
718
1071
  }
@@ -722,6 +1075,8 @@ export function buildTmBrowserTool(deps) {
722
1075
  dialogs: [],
723
1076
  consoleBuf: [],
724
1077
  netBuf: [],
1078
+ allowedSites: new Set(),
1079
+ blocked: { count: 0, hosts: new Set() },
725
1080
  };
726
1081
  const attached = new WeakSet();
727
1082
  const attach = (p) => {
@@ -775,22 +1130,12 @@ export function buildTmBrowserTool(deps) {
775
1130
  await route.continue().catch(() => { });
776
1131
  return;
777
1132
  }
778
- const verdict = checkWebUrl(u, allowlist);
779
- let pass = verdict.ok;
780
- if (!pass) {
781
- try {
782
- pass = approvedHosts.has(new URL(u).hostname);
783
- }
784
- catch {
785
- pass = false;
786
- }
787
- }
1133
+ const { pass } = gateDecide(state, u, request.resourceType?.());
788
1134
  if (pass) {
789
1135
  await route.continue().catch(() => { });
790
1136
  }
791
1137
  else {
792
1138
  // per-hop re-check lands here for every redirect hop too
793
- traj({ step_id: "browser", event: "blocked", url: u.slice(0, 200) });
794
1139
  state.netBuf.push({ method: request.method?.() ?? "?", url: u.slice(0, 300), blocked: true });
795
1140
  await route.abort().catch(() => { });
796
1141
  }
@@ -839,25 +1184,34 @@ export function buildTmBrowserTool(deps) {
839
1184
  const sess = {
840
1185
  kind: "playwright",
841
1186
  currentUrl: "about:blank",
1187
+ headless,
1188
+ label: `playwright/${via} · ${path.basename(executable)} · ${headless ? "headless" : "headful"}`,
1189
+ attachments: [],
1190
+ blocked: state.blocked,
1191
+ allowedSites: state.allowedSites,
842
1192
  async act(action, args, stepId) {
843
1193
  if (action === "navigate" || action === "navigate_page") {
844
1194
  const url = String(args.url ?? "").trim();
1195
+ // the navigation itself already cleared the allowlist (execute()
1196
+ // asked the user otherwise) — its SITE may now load subresources
1197
+ rememberSite(state.allowedSites, url);
845
1198
  // domcontentloaded ONLY — networkidle is banned (prompt discipline)
846
1199
  await state.page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
847
1200
  sess.currentUrl = state.page.url?.() || url;
848
- return `已导航:${url}`;
1201
+ rememberSite(state.allowedSites, sess.currentUrl);
1202
+ return `已导航:${url}${blockedNote(sess)}`;
849
1203
  }
850
1204
  if (action === "read") {
851
1205
  traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
852
1206
  const text = String((await state.page.evaluate("document.body ? document.body.innerText : ''")) ?? "");
853
1207
  traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
854
- return pageTextOutput(sess.currentUrl, text);
1208
+ return `${pageTextOutput(sess.currentUrl, text)}${blockedNote(sess)}`;
855
1209
  }
856
1210
  if (action === "screenshot" || action === "take_screenshot") {
857
1211
  traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
858
1212
  const bytes = await state.page.screenshot({ type: "png", fullPage: Boolean(args.fullPage) });
859
1213
  const { file, png } = saveShotPng(bytes, stepId);
860
- return shotOutput(file, png.length);
1214
+ return shotOutput(sess, file, png, args, async () => Buffer.from(await state.page.screenshot({ type: "jpeg", quality: 70, fullPage: Boolean(args.fullPage) })));
861
1215
  }
862
1216
  if (action === "take_snapshot") {
863
1217
  traj({ step_id: stepId, event: "call", kind: "snapshot", url: sess.currentUrl.slice(0, 200) });
@@ -866,13 +1220,13 @@ export function buildTmBrowserTool(deps) {
866
1220
  yaml = await state.page.locator("body").ariaSnapshot();
867
1221
  }
868
1222
  catch (e) {
869
- throw new Error(`ariaSnapshot 不可用(playwright-core 版本过旧或缺失):${e.message} —— 升级 playwright-core(需 ≥1.65 才有 locator.ariaSnapshot)`);
1223
+ throw new Error(`ariaSnapshot 不可用:${e.message} —— locator.ariaSnapshot 需要 playwright-core ≥1.49(本包 optionalDependencies 锁 1.63);未安装或过旧会走 cdp-legacy 降级,那边没有快照动作。`);
870
1224
  }
871
1225
  const annotated = snapIndex.annotate(yaml);
872
1226
  traj({ step_id: stepId, event: "result", tokens: estimateTokens(annotated), nodes: snapIndex.size });
873
1227
  const body = capTokens(annotated, `…(快照超过 browserSnapshotMaxTokens=${snapshotBudget},后续行已截断——用 read 拿原文或先滚动再快照)`);
874
1228
  const hint = state.dialogs.length ? `\n注意:有 ${state.dialogs.length} 个未处理对话框——先 handle_dialog` : "";
875
- return `ARIA 快照(${sess.currentUrl} · ${snapIndex.size} 个可寻址节点)——后续动作按行内 [uid=eN] 寻址:\n${body}${hint}`;
1229
+ return `ARIA 快照(${sess.currentUrl} · ${snapIndex.size} 个可寻址节点)——后续动作按行内 [uid=eN] 寻址:\n${body}${hint}${blockedNote(sess)}`;
876
1230
  }
877
1231
  if (action === "click") {
878
1232
  const loc = targetOf("uid", "selector", args);
@@ -984,20 +1338,63 @@ export function buildTmBrowserTool(deps) {
984
1338
  },
985
1339
  async close() {
986
1340
  state.dialogs.length = 0;
987
- await Promise.resolve(context.close()).catch(() => { });
1341
+ const errs = [];
1342
+ const fail = (label2) => (e) => {
1343
+ errs.push(`${label2}: ${String(e?.message ?? e)}`);
1344
+ };
1345
+ await Promise.resolve(context.close()).catch(fail("context"));
988
1346
  if (browser)
989
- await Promise.resolve(browser.close()).catch(() => { });
990
- if (persistentDir) {
991
- return `浏览器会话已关闭;持久配置目录已保留(下次 open 复用登录态):${persistentDir}`;
1347
+ await Promise.resolve(browser.close()).catch(fail("browser"));
1348
+ // VERIFY before claiming — an agent quotes this line to the user as
1349
+ // "浏览器已关闭", so a swallowed failure must not read like success.
1350
+ let alivePages = 0;
1351
+ try {
1352
+ alivePages = context.pages().length;
1353
+ }
1354
+ catch {
1355
+ alivePages = 0;
992
1356
  }
993
- // playwright owns (and self-reclaims) the ephemeral profile under
994
- // the browser process — nothing on our side to delete
995
- return "浏览器会话已关闭,临时配置目录已清理。";
1357
+ const connected = browser ? Boolean(browser.isConnected?.()) : false;
1358
+ const closed = errs.length === 0 && alivePages === 0 && !connected;
1359
+ const profile = persistentDir
1360
+ ? `持久配置目录已保留(下次 open 复用登录态):${persistentDir}`
1361
+ : "临时配置目录由 playwright 自行回收";
1362
+ if (closed)
1363
+ return { closed: true, text: `浏览器会话已确认关闭(${sess.label});${profile}。` };
1364
+ return {
1365
+ closed: false,
1366
+ text: `警告:关闭未完全成功(${sess.label}${errs.length ? `:${errs.join("; ")}` : ""})——` +
1367
+ `残留标签页 ${alivePages} 个${connected ? ",浏览器进程仍处于连接状态" : ""}。` +
1368
+ `窗口可能仍在前台,需要用户手动关闭。${profile}。`,
1369
+ };
996
1370
  },
997
1371
  };
998
1372
  return sess;
999
1373
  }
1000
1374
  // ---------- engine-neutral session lifecycle ----------
1375
+ /** Idle reaper. The pre-v1.5.13 layer had NO cleanup path other than the
1376
+ * agent remembering to call `close` — and an agent that believes it
1377
+ * closed the window (issue #3) leaves the user staring at a Chromium
1378
+ * window nobody owns. An untouched session now closes itself and tells
1379
+ * the user it did. */
1380
+ let idleTimer = null;
1381
+ const armIdle = () => {
1382
+ if (idleTimer)
1383
+ clearTimeout(idleTimer);
1384
+ idleTimer = null;
1385
+ if (!idleCloseMs || !session)
1386
+ return;
1387
+ idleTimer = setTimeout(() => {
1388
+ idleTimer = null;
1389
+ if (!session)
1390
+ return;
1391
+ traj({ step_id: "browser", event: "idle_close", idle_ms: idleCloseMs });
1392
+ void closeActive()
1393
+ .then((r) => notify?.(`tm_browser 空闲 ${Math.round(idleCloseMs / 1000)}s,会话已自动关闭${r.closed ? "(窗口应已消失)" : "——但窗口可能仍需手动关闭"}`))
1394
+ .catch(() => { });
1395
+ }, idleCloseMs);
1396
+ idleTimer.unref?.();
1397
+ };
1001
1398
  async function ensureSession(headless) {
1002
1399
  if (session)
1003
1400
  return session;
@@ -1011,10 +1408,26 @@ export function buildTmBrowserTool(deps) {
1011
1408
  async function closeActive() {
1012
1409
  const s = session;
1013
1410
  if (!s)
1014
- return "没有打开的浏览器会话。";
1411
+ return { text: "没有打开的浏览器会话。", closed: true };
1015
1412
  session = null;
1413
+ if (idleTimer) {
1414
+ clearTimeout(idleTimer);
1415
+ idleTimer = null;
1416
+ }
1016
1417
  return s.close();
1017
1418
  }
1419
+ /** Move any screenshot pixels act() produced out of the session and onto
1420
+ * the ToolResult. Drained per call so a stale attachment can never ride
1421
+ * along on the next action. */
1422
+ function withAttachments(res, sess) {
1423
+ const atts = sess?.attachments ?? [];
1424
+ if (sess)
1425
+ sess.attachments = [];
1426
+ if (!atts.length)
1427
+ return res;
1428
+ const base = typeof res === "string" ? { output: res } : res;
1429
+ return { ...base, attachments: atts };
1430
+ }
1018
1431
  const ALL_ACTIONS = new Set([...BROWSER_PLAYWRIGHT_ACTIONS, ...BROWSER_COMPAT_ACTIONS, "navigate_page"]);
1019
1432
  const execute = async (rawArgs, ctx) => {
1020
1433
  try {
@@ -1048,30 +1461,46 @@ export function buildTmBrowserTool(deps) {
1048
1461
  if (!url)
1049
1462
  return toToolResult(tmError(tool, "args", "缺少 url 参数"));
1050
1463
  if (session) {
1051
- // already open: navigate instead of spawning a second browser
1052
- return toToolResult(await session.act("navigate", { url }, "browser"));
1464
+ // already open: navigate instead of spawning a second browser.
1465
+ // Report the mode the RUNNING instance is actually in — the old
1466
+ // code echoed the REQUESTED mode, so a sticky session could be
1467
+ // described as headful while it was a headless leftover.
1468
+ const out = await session.act("navigate", { url }, "browser");
1469
+ armIdle();
1470
+ return withAttachments(toToolResult(`复用已运行的会话(${session.headless ? "无头" : "有头窗口"} · ${session.label})。${out}`), session);
1053
1471
  }
1054
- const headless = args.headless != null ? Boolean(args.headless) : resolveHeadless(env);
1472
+ // Mode is an OPERATOR decision (TM_BROWSER_HEADLESS), never a model
1473
+ // arg: a model that once passed headless:"false" (string → truthy
1474
+ // under Boolean()) pinned the whole process to headless and every
1475
+ // anti-bot gate in the run failed.
1476
+ const headless = resolveHeadless(env);
1055
1477
  const s = await ensureSession(headless);
1056
1478
  const out = await s.act("navigate", { url }, "browser");
1057
- traj({ step_id: "browser", event: "open", headless });
1058
- const mode = headless ? "无头" : "有头窗口";
1479
+ traj({ step_id: "browser", event: "open", headless, label: s.label });
1480
+ const mode = s.headless ? "无头" : "有头窗口";
1059
1481
  const profile = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim() ? "持久登录配置" : "隔离临时配置";
1060
1482
  const note = lastSel && preference !== "cdp-legacy" && lastSel.kind === "cdp-legacy"
1061
1483
  ? `\n(引擎:cdp-legacy 降级——${lastSel.reason ?? ""};完整 16 动作需 npm install playwright-core + node≥20)`
1062
1484
  : "";
1063
- return toToolResult(`浏览器已启动(${mode},${profile})。${out}${note}`);
1485
+ const guard = subPolicy === "off"
1486
+ ? `\n(子资源策略=off:跨域图片/脚本一律拦截,页面可能显示不全——TM_BROWSER_SUBRESOURCE=same-site 可恢复)`
1487
+ : "";
1488
+ armIdle();
1489
+ return withAttachments(toToolResult(`浏览器已启动(${mode},${profile} · ${s.label})。${out}${note}${guard}`), s);
1064
1490
  }
1065
1491
  if (action === "navigate" || action === "navigate_page") {
1066
1492
  if (!url)
1067
1493
  return toToolResult(tmError(tool, "args", "缺少 url 参数"));
1068
1494
  if (!session)
1069
1495
  return toToolResult(tmError(tool, "args", '没有打开的浏览器会话——先用 action:"open"'));
1070
- return toToolResult(await session.act(action, { url }, "browser"));
1496
+ const out = await session.act(action, { url }, "browser");
1497
+ armIdle();
1498
+ return withAttachments(toToolResult(out), session);
1071
1499
  }
1072
1500
  if (action === "close") {
1073
1501
  traj({ step_id: "browser", event: "close" });
1074
- return toToolResult(await closeActive());
1502
+ const r = await closeActive();
1503
+ return toToolResult(r.text);
1075
1504
  }
1076
1505
  if (!ALL_ACTIONS.has(action)) {
1077
1506
  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=…] 寻址)`));
@@ -1088,8 +1517,47 @@ export function buildTmBrowserTool(deps) {
1088
1517
  return toToolResult(tmError(tool, "permission", refusal));
1089
1518
  }
1090
1519
  }
1520
+ // M5 (v1.5.14): evaluate_script's consent + result redaction run at the
1521
+ // execute layer for BOTH engines — same reason upload_file's source
1522
+ // check does: the engine act() has no host ctx, and one choke point
1523
+ // cannot drift from the other.
1524
+ if (action === "evaluate_script" && needsEvalConsent(evalAskPolicy, session.evalApproved)) {
1525
+ const target = hostOnly(session.currentUrl);
1526
+ const outcome = await askUserForTarget(ctx, {
1527
+ permission: tool,
1528
+ patterns: [`evaluate_script:${target}`],
1529
+ metadata: {
1530
+ tool,
1531
+ action: "evaluate_script",
1532
+ host: target,
1533
+ source: String(args.function ?? args.expression ?? "").slice(0, 160),
1534
+ },
1535
+ });
1536
+ traj({ step_id: "browser", event: "eval_consent", host: target, verdict: outcome });
1537
+ if (outcome !== "approved") {
1538
+ return toToolResult(tmError(tool, "permission", outcome === "unavailable"
1539
+ ? `evaluate_script 需要官方确认窗授权,本宿主没有 ask 桥——已拒绝执行。TM_BROWSER_ASK_EVAL=off 可关掉这道确认(不建议:这个动词能在你已登录的浏览器里读任意页面数据)。`
1540
+ : `evaluate_script 未获批准(目标站点 ${target}),已拒绝执行。改用 take_snapshot/read 的受治理读取,或让用户单独批准。`));
1541
+ }
1542
+ session.evalApproved = true;
1543
+ }
1091
1544
  const stepId = pipelines.nextStepId();
1092
- return toToolResult(await session.act(action, args, stepId));
1545
+ const out = await session.act(action, args, stepId);
1546
+ if (action !== "evaluate_script") {
1547
+ armIdle();
1548
+ return withAttachments(toToolResult(out), session);
1549
+ }
1550
+ // The value is masked BEFORE it can reach the context window, the run
1551
+ // store or the trajectory; the agent is told a mask happened (a silent
1552
+ // blank would read as "the page has no token").
1553
+ const red = redactEvalResult(out);
1554
+ if (red.masked.length) {
1555
+ traj({ step_id: "browser", event: "eval_redacted", kinds: red.masked.join(",") });
1556
+ armIdle();
1557
+ return withAttachments(toToolResult(`${red.text}\n注意:结果里有 ${red.masked.join("、")} 被识别为密钥形状并已脱敏——不是页面没有这些值,是插件不把它们送进上下文。需要它们请让用户自己在浏览器里看。`), session);
1558
+ }
1559
+ armIdle();
1560
+ return withAttachments(toToolResult(red.text), session);
1093
1561
  }
1094
1562
  catch (err) {
1095
1563
  const e = err;
@@ -1097,17 +1565,18 @@ export function buildTmBrowserTool(deps) {
1097
1565
  }
1098
1566
  };
1099
1567
  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.
1100
- - 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? } → PNG path into the run store (pixels never enter context) · handle_dialog { dialogAction: accept|dismiss, promptText }.
1101
- - Compat actions: open { url } — launch/reuse + navigate; TM_BROWSER_PATH override → DEFAULT browser (Chromium-family only; Edge/Chrome probes fall through when it is Firefox). 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.
1568
+ - 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 } (arbitrary JS in YOUR browser: needs one official-dialog consent per browser session, and the result is scanned so JWT/bearer/cookie/api-key shapes never enter the context) · 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 }.
1569
+ - 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.
1570
+ - 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.
1102
1571
  - 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.
1103
- - Network: per-request domain allowlist enforced INSIDE the page (playwright context.route abort / CDP Fetch interception), re-checked on every redirect hop; seeded = tm_webfetch's hosts (extend via TM_WEBFETCH_ALLOWED_DOMAINS). Out-of-allowlist open/navigate routes through the OFFICIAL confirmation dialog BEFORE any spawn; approved hosts pass the network layer for the session; env-file URLs and non-http(s) schemes hard-reject.
1572
+ - 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.
1104
1573
  - 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).`;
1105
1574
  return {
1106
1575
  description: DESCRIPTION,
1107
1576
  args: deps.args ?? {
1108
1577
  action: { descriptor: "action: take_snapshot|click|fill|navigate_page|... (16 playwright verbs + open/navigate/read/screenshot/close)" },
1109
1578
  url: { descriptor: "url: string (open/navigate/navigate_page, allowlisted https)" },
1110
- headless: { descriptor: "headless: boolean (open, optional — default auto)" },
1579
+ image: { descriptor: "image: true (take_screenshot only — attach the PNG pixels to this result; default is path-only)" },
1111
1580
  uid: { descriptor: "uid: snapshot [uid=eN] token (click/fill/hover/drag/upload_file/wait_for)" },
1112
1581
  selector: { descriptor: "selector: CSS/text locator escape hatch (only when a snapshot cannot express the node)" },
1113
1582
  targetUid: { descriptor: "targetUid: drag destination uid" },
@@ -1124,8 +1593,14 @@ export function buildTmBrowserTool(deps) {
1124
1593
  clear: { descriptor: "clear: drain the console buffer after list_console_messages" },
1125
1594
  },
1126
1595
  execute,
1127
- dispose: () => {
1128
- void closeActive().catch(() => { });
1596
+ dispose: async () => {
1597
+ if (idleTimer) {
1598
+ clearTimeout(idleTimer);
1599
+ idleTimer = null;
1600
+ }
1601
+ // AWAITED: the host's dispose hook is Promise-returning, and tearing
1602
+ // down while a close was still in flight left the window on screen.
1603
+ await closeActive().catch(() => { });
1129
1604
  },
1130
1605
  engineInfo: () => ({
1131
1606
  kind: lastSel?.kind ?? null,