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

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 (89) hide show
  1. package/README.md +129 -53
  2. package/README.zh-CN.md +103 -45
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +17 -11
  5. package/dist/agents.js.map +1 -1
  6. package/dist/approval-gate.d.ts +54 -16
  7. package/dist/approval-gate.d.ts.map +1 -1
  8. package/dist/approval-gate.js +163 -42
  9. package/dist/approval-gate.js.map +1 -1
  10. package/dist/prompts/lead.d.ts +1 -1
  11. package/dist/prompts/lead.d.ts.map +1 -1
  12. package/dist/prompts/lead.js +22 -3
  13. package/dist/prompts/lead.js.map +1 -1
  14. package/dist/prompts/shared.d.ts +1 -1
  15. package/dist/prompts/shared.d.ts.map +1 -1
  16. package/dist/prompts/shared.js +154 -119
  17. package/dist/prompts/shared.js.map +1 -1
  18. package/dist/prompts/specialists.d.ts +2 -2
  19. package/dist/prompts/specialists.d.ts.map +1 -1
  20. package/dist/prompts/specialists.js +40 -11
  21. package/dist/prompts/specialists.js.map +1 -1
  22. package/dist/tm/args-schema.d.ts +25 -1
  23. package/dist/tm/args-schema.d.ts.map +1 -1
  24. package/dist/tm/args-schema.js +149 -39
  25. package/dist/tm/args-schema.js.map +1 -1
  26. package/dist/tm/browser.d.ts +188 -19
  27. package/dist/tm/browser.d.ts.map +1 -1
  28. package/dist/tm/browser.js +781 -111
  29. package/dist/tm/browser.js.map +1 -1
  30. package/dist/tm/config.d.ts +61 -0
  31. package/dist/tm/config.d.ts.map +1 -1
  32. package/dist/tm/config.js +55 -1
  33. package/dist/tm/config.js.map +1 -1
  34. package/dist/tm/index.d.ts +5 -3
  35. package/dist/tm/index.d.ts.map +1 -1
  36. package/dist/tm/index.js +36 -2
  37. package/dist/tm/index.js.map +1 -1
  38. package/dist/tm/memory.d.ts +88 -10
  39. package/dist/tm/memory.d.ts.map +1 -1
  40. package/dist/tm/memory.js +881 -99
  41. package/dist/tm/memory.js.map +1 -1
  42. package/dist/tm/pipelines.d.ts +26 -3
  43. package/dist/tm/pipelines.d.ts.map +1 -1
  44. package/dist/tm/pipelines.js +167 -11
  45. package/dist/tm/pipelines.js.map +1 -1
  46. package/dist/tm/ptc/budgets.d.ts +22 -1
  47. package/dist/tm/ptc/budgets.d.ts.map +1 -1
  48. package/dist/tm/ptc/budgets.js +87 -8
  49. package/dist/tm/ptc/budgets.js.map +1 -1
  50. package/dist/tm/ptc/contract.d.ts +49 -9
  51. package/dist/tm/ptc/contract.d.ts.map +1 -1
  52. package/dist/tm/ptc/contract.js +41 -3
  53. package/dist/tm/ptc/contract.js.map +1 -1
  54. package/dist/tm/ptc/driver.d.ts +5 -2
  55. package/dist/tm/ptc/driver.d.ts.map +1 -1
  56. package/dist/tm/ptc/driver.js +47 -15
  57. package/dist/tm/ptc/driver.js.map +1 -1
  58. package/dist/tm/ptc/engines.d.ts +15 -4
  59. package/dist/tm/ptc/engines.d.ts.map +1 -1
  60. package/dist/tm/ptc/engines.js +281 -28
  61. package/dist/tm/ptc/engines.js.map +1 -1
  62. package/dist/tm/ptc/index.d.ts +4 -4
  63. package/dist/tm/ptc/index.d.ts.map +1 -1
  64. package/dist/tm/ptc/index.js +2 -2
  65. package/dist/tm/ptc/index.js.map +1 -1
  66. package/dist/tm/ptc/pscan.d.ts +20 -1
  67. package/dist/tm/ptc/pscan.d.ts.map +1 -1
  68. package/dist/tm/ptc/pscan.js +328 -14
  69. package/dist/tm/ptc/pscan.js.map +1 -1
  70. package/dist/tm/ptc/summary.d.ts.map +1 -1
  71. package/dist/tm/ptc/summary.js +23 -1
  72. package/dist/tm/ptc/summary.js.map +1 -1
  73. package/dist/tm/ptc/tool.d.ts +34 -3
  74. package/dist/tm/ptc/tool.d.ts.map +1 -1
  75. package/dist/tm/ptc/tool.js +117 -20
  76. package/dist/tm/ptc/tool.js.map +1 -1
  77. package/dist/tm/search.d.ts +83 -14
  78. package/dist/tm/search.d.ts.map +1 -1
  79. package/dist/tm/search.js +440 -45
  80. package/dist/tm/search.js.map +1 -1
  81. package/dist/tm/shell-bridge.d.ts +14 -1
  82. package/dist/tm/shell-bridge.d.ts.map +1 -1
  83. package/dist/tm/shell-bridge.js +57 -10
  84. package/dist/tm/shell-bridge.js.map +1 -1
  85. package/dist/tm/webfetch.d.ts +25 -7
  86. package/dist/tm/webfetch.d.ts.map +1 -1
  87. package/dist/tm/webfetch.js +88 -14
  88. package/dist/tm/webfetch.js.map +1 -1
  89. package/package.json +5 -2
@@ -1,38 +1,71 @@
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: playwright
34
+ * context.route() abort / CDP Fetch.requestPaused fail — every request
35
+ * and every redirect hop re-checked (per-hop analog);
36
+ * - out-of-allowlist open/navigate routes through the OFFICIAL dialog
37
+ * BEFORE any spawn; approved hosts also pass the network layer;
38
+ * - hardened context: real-Chrome UA + zh-CN Accept-Language,
39
+ * no-first-run / no-extensions / mute launch flags, 3 s default action
40
+ * timeout (the prompt's "3000 ms budget" is the tool's default, not an
41
+ * aspiration), no networkidle waits anywhere;
42
+ * - dispose kills the child / closes the browser.
15
43
  *
16
44
  * Environment adaptivity (different OpenCode hosts):
17
45
  * - headful by default (Plan C); display-less Linux (no DISPLAY/
18
46
  * WAYLAND_DISPLAY) automatically falls back to headless; TM_BROWSER_
19
47
  * 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.
48
+ * - browser discovery (SHARED by both engines): TM_BROWSER_PATH
49
+ * override, then the USER'S DEFAULT browser (Windows registry / Linux
50
+ * xdg-settings) when it is Chromium-family — CDP is Chromium-proprietary
51
+ * and playwright channels only speak Chromium too, so a Firefox default
52
+ * falls through — then per-OS candidate paths (Edge first on Windows;
53
+ * Chrome/Chromium elsewhere). No browser → structured error, the agent
54
+ * falls back to tm_webfetch / user MCP tools.
26
55
  *
27
- * Role access mirrors tm_webfetch: network roles (team + researcher) only.
56
+ * Role access mirrors tm_webfetch: network roles (team + researcher) only,
57
+ * tester browser-only (UI verification).
28
58
  */
29
59
  import { execFileSync, spawn } from "node:child_process";
30
60
  import * as fs from "node:fs";
31
61
  import * as os from "node:os";
32
62
  import * as path from "node:path";
33
- import { checkWebUrl } from "./webfetch.js";
63
+ import { checkWebUrl, seedWebfetchDomains } from "./webfetch.js";
34
64
  import { askUserForTarget } from "./perm-ask.js";
65
+ import { assertReadablePath } from "./guard.js";
66
+ import { isEnvFilePath } from "../envprotect.js";
35
67
  import { tmError, toToolResult } from "./result.js";
68
+ import { estimateTokens } from "./config.js";
36
69
  import { rmForceSafe } from "../fs-safe.js";
37
70
  /** Per-OS browser candidates, in preference order (first hit wins). */
38
71
  const BROWSER_CANDIDATES = {
@@ -68,7 +101,9 @@ export function parseProgId(stdout) {
68
101
  const m = /ProgId\s+REG_SZ\s+(\S+)/i.exec(String(stdout ?? ""));
69
102
  return m ? m[1] : null;
70
103
  }
71
- /** Parse `reg query "...\shell\open\command" /ve` output → exe path. */
104
+ /** Parse `reg query "...\shell\open\command" /ve` output → exe path.
105
+ * The quoted capture stops at the closing quote, so the `" -- %1"` style
106
+ * URL-template tail never leaks into the returned path. */
72
107
  export function parseRegCommand(stdout) {
73
108
  const m = /REG_SZ\s+(?:"([^"]+)"|(\S+))/i.exec(String(stdout ?? ""));
74
109
  return m ? (m[1] ?? m[2]) : null;
@@ -171,7 +206,7 @@ class PipeCdp {
171
206
  pending = new Map();
172
207
  buf = Buffer.alloc(0);
173
208
  events = [];
174
- onEvent = (handler) => this.eventHandler = handler;
209
+ onEvent = (handler) => (this.eventHandler = handler);
175
210
  eventHandler = () => { };
176
211
  constructor(child) {
177
212
  this.child = child;
@@ -230,28 +265,262 @@ class PipeCdp {
230
265
  }
231
266
  }
232
267
  }
268
+ /** playwright-core 1.63 targets node >= 20; on anything older we refuse the
269
+ * engine up-front and stay on the zero-dependency CDP pipe (the node>=18
270
+ * package floor keeps the plugin itself loadable — only the playwright
271
+ * PRIMARY is gated here, cdp-legacy carries node 18/19 hosts). */
272
+ export const PLAYWRIGHT_MIN_NODE_MAJOR = 20;
273
+ /** "v22.3.1" / "20" -> major number; unparseable -> 0 (fail toward the
274
+ * always-available cdp-legacy engine). */
275
+ export function nodeMajorOf(version) {
276
+ const m = /^v?(\d+)/.exec(String(version ?? "").trim());
277
+ return m ? Number(m[1]) : 0;
278
+ }
279
+ /** The REAL loader — a VARIABLE specifier so tsc never tries to resolve
280
+ * "playwright-core" at build time (types ship inside the package, which may
281
+ * be absent from node_modules). Under ESM, import(variable) yields the
282
+ * module namespace typed as any. */
283
+ export async function defaultImportPlaywright() {
284
+ const specifier = "playwright-core";
285
+ return await import(specifier);
286
+ }
287
+ /** Pure engine-selection seam (exported for test-browser.mjs):
288
+ * cdp-legacy preference → legacy WITHOUT ever importing; node < 20 →
289
+ * legacy without importing; playwright preference → import, verify the
290
+ * chromium.launch shape, degrade to legacy on ANY failure. */
291
+ export async function selectBrowserEngine(opts) {
292
+ const preference = opts.preference === "cdp-legacy" ? "cdp-legacy" : "playwright";
293
+ if (preference === "cdp-legacy") {
294
+ return { kind: "cdp-legacy", reason: "TM_BROWSER_ENGINE=cdp-legacy 偏好" };
295
+ }
296
+ if (opts.nodeMajor < PLAYWRIGHT_MIN_NODE_MAJOR) {
297
+ return {
298
+ kind: "cdp-legacy",
299
+ reason: `node ${opts.nodeMajor || "?"} < ${PLAYWRIGHT_MIN_NODE_MAJOR} — playwright-core 不受支持,自动降级 cdp-legacy`,
300
+ };
301
+ }
302
+ try {
303
+ const mod = (await opts.importPlaywright());
304
+ if (!mod || typeof mod.chromium?.launch !== "function") {
305
+ throw new Error("playwright-core 模块形状异常(缺 chromium.launch)");
306
+ }
307
+ return { kind: "playwright", pw: mod };
308
+ }
309
+ catch (e) {
310
+ return {
311
+ kind: "cdp-legacy",
312
+ reason: `playwright-core 加载失败:${e?.message ?? String(e)} — 自动降级 cdp-legacy(快照类动作不可用)`,
313
+ };
314
+ }
315
+ }
316
+ /** ariaSnapshot yields plain YAML with NO addressable id, while the
317
+ * chrome-devtools-mcp muscle memory this tool aligns to is uid-addressed.
318
+ * The missing layer is built here: each take_snapshot re-indexes every YAML
319
+ * node (`- <role> "<name>" …`), injects `[uid=eN]` inline, and remembers
320
+ * (role, name, nth-of-kind); later actions resolve uid →
321
+ * `page.getByRole(role, { name, exact: true }).nth(nth)`. Uids are
322
+ * renumbered on every snapshot (one action, one observation); an
323
+ * unknown/stale uid errors back to take_snapshot instead of falling back
324
+ * to guessed locators. */
325
+ export class SnapshotIndex {
326
+ entries = new Map();
327
+ next = 0;
328
+ get size() {
329
+ return this.entries.size;
330
+ }
331
+ /** Reset + parse + annotate the ariaSnapshot YAML in one pass. */
332
+ annotate(yaml) {
333
+ this.entries.clear();
334
+ this.next = 0;
335
+ const counters = new Map();
336
+ const out = [];
337
+ for (const line of String(yaml ?? "").split(/\r?\n/)) {
338
+ // `- role`, `- role "name"`, `- role:`, `- role "name" [attrs]` …
339
+ // the `text` pseudo-role carries no interactive semantics: skip it.
340
+ const m = /^(\s*-\s+)([A-Za-z][\w-]*)((?:"[^"]*"|.)*?)$/.exec(line);
341
+ if (!m || m[2] === "text") {
342
+ out.push(line);
343
+ continue;
344
+ }
345
+ const role = m[2];
346
+ const rest = m[3];
347
+ const uid = `e${++this.next}`;
348
+ const rawName = /"((?:[^"\\]|\\.)*)"/.exec(rest)?.[1];
349
+ const name = rawName === undefined ? undefined : unescapeAriaName(rawName);
350
+ const key = `${role}\u0000${name ?? ""}`;
351
+ const nth = counters.get(key) ?? 0;
352
+ counters.set(key, nth + 1);
353
+ this.entries.set(uid, { role, name, nth });
354
+ // container lines (`- list:`) get the uid BEFORE the colon so the
355
+ // YAML `key:` child-anchor semantics stay intact; names may contain
356
+ // colons, hence the startsWith check instead of a split.
357
+ out.push(rest.startsWith(":") ? `${m[1]}${role} [uid=${uid}]${rest}` : `${line} [uid=${uid}]`);
358
+ }
359
+ return out.join("\n");
360
+ }
361
+ get(uid) {
362
+ return this.entries.get(String(uid ?? "").trim());
363
+ }
364
+ /** uid → playwright locator; null = unknown uid / role the engine cannot
365
+ * address (the caller renders the take_snapshot-first guidance). */
366
+ locatorFor(page, uid) {
367
+ const entry = this.get(uid);
368
+ if (!entry)
369
+ return null;
370
+ try {
371
+ const base = entry.name !== undefined ? page.getByRole(entry.role, { name: entry.name, exact: true }) : page.getByRole(entry.role);
372
+ return base.nth(entry.nth);
373
+ }
374
+ catch {
375
+ return null;
376
+ }
377
+ }
378
+ }
379
+ /** ariaSnapshot escapes whitespace/quotes inside names (\s \n \t \" \\). */
380
+ export function unescapeAriaName(raw) {
381
+ return String(raw).replace(/\\(.)/g, (_, c) => (c === "s" ? " " : c === "n" ? "\n" : c === "t" ? "\t" : c));
382
+ }
383
+ // ---------- action surface (R2: chrome-devtools-mcp 16-verb alignment) --------
384
+ /** The 16 canonical verbs, named byte-identical to chrome-devtools-mcp. */
385
+ export const BROWSER_PLAYWRIGHT_ACTIONS = [
386
+ "navigate_page",
387
+ "take_snapshot",
388
+ "click",
389
+ "fill",
390
+ "hover",
391
+ "drag",
392
+ "press_key",
393
+ "select_page",
394
+ "upload_file",
395
+ "wait_for",
396
+ "evaluate_script",
397
+ "list_console_messages",
398
+ "list_network_requests",
399
+ "list_pages",
400
+ "take_screenshot",
401
+ "handle_dialog",
402
+ ];
403
+ /** Legacy compat verbs that ride on top of the 16 (both engines). */
404
+ export const BROWSER_COMPAT_ACTIONS = ["open", "navigate", "read", "screenshot", "close"];
405
+ /** What the cdp-legacy fallback can still do (R2: degradation must keep the
406
+ * §6o behavior; everything snapshot-shaped is playwright-only). */
407
+ export const BROWSER_LEGACY_ACTIONS = [
408
+ "open",
409
+ "navigate",
410
+ "navigate_page",
411
+ "read",
412
+ "screenshot",
413
+ "take_screenshot",
414
+ "list_pages",
415
+ "evaluate_script",
416
+ "close",
417
+ ];
418
+ /** Default action/wait budget — the prompt's "3000 ms" discipline is the
419
+ * ENGINE default, so agents that forget a timeout still fail fast. */
420
+ const PW_DEFAULT_TIMEOUT_MS = 3000;
421
+ const OBSERVE_BUFFER_CAP = 200;
422
+ 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
423
  const SPAWN_TIMEOUT_MS = 15_000;
234
424
  const NAVIGATE_EVENT_TIMEOUT_MS = 15_000;
235
- // ---------- tool ----------------------------------------------------------------
236
425
  export function buildTmBrowserTool(deps) {
237
426
  const { pipelines } = deps;
238
427
  const env = deps.env ?? process.env;
239
428
  const tool = "tm_browser";
240
429
  const traj = (e) => pipelines.store.appendTrajectory({ tool, ...e });
241
430
  let session = null;
242
- const allowlist = deps.cfg.webfetchAllowedDomains
243
- ?? ["*"];
431
+ // m2 (fix round): SAME seed policy as tm_webfetch / tm_search — the
432
+ // built-in default list gains the T4 engine home hosts (api.stackexchange
433
+ // .com, hn.algolia.com) so jump-reading a search hit stops re-popping the
434
+ // dialog; a narrowed TM_WEBFETCH_ALLOWED_DOMAINS is never widened.
435
+ const allowlist = seedWebfetchDomains(deps.cfg.webfetchAllowedDomains ?? ["*"]);
244
436
  // hosts approved through the OFFICIAL dialog this plugin lifetime — the
245
- // CDP network-layer block consults this in addition to the static allowlist
437
+ // network layer (both engines) consults this in addition to the static allowlist
246
438
  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 工具。");
439
+ const snapshotBudget = Math.max(10, Number(deps.cfg?.browserSnapshotMaxTokens) || 1200);
440
+ // ---------- engine selection (cached per plugin instance) ----------
441
+ let selPromise = null;
442
+ let lastSel = null;
443
+ const preference = String(deps.cfg?.browserEngine ?? "playwright");
444
+ const selection = () => {
445
+ if (!selPromise) {
446
+ selPromise = selectBrowserEngine({
447
+ preference,
448
+ nodeMajor: deps.nodeMajor ?? nodeMajorOf(process.versions.node),
449
+ importPlaywright: deps.importPlaywright ?? defaultImportPlaywright,
450
+ }).then((sel) => {
451
+ lastSel = sel;
452
+ traj({ step_id: "browser", event: "engine", kind: sel.kind, reason: (sel.reason ?? "").slice(0, 200) });
453
+ return sel;
454
+ });
455
+ }
456
+ return selPromise;
457
+ };
458
+ /** Observe-payload cap shared by snapshot / console / network / eval. */
459
+ function capTokens(text, note) {
460
+ if (estimateTokens(text) <= snapshotBudget)
461
+ return text;
462
+ const lines = text.split("\n");
463
+ let acc = 0;
464
+ let i = 0;
465
+ for (; i < lines.length; i++) {
466
+ const cost = estimateTokens(lines[i]) + 1;
467
+ if (acc + cost > snapshotBudget)
468
+ break;
469
+ acc += cost;
254
470
  }
471
+ return `${lines.slice(0, i).join("\n")}\n${note}`;
472
+ }
473
+ function saveShotPng(bytes, stepId) {
474
+ const png = Buffer.from(bytes);
475
+ const dir = path.join(pipelines.store.stepsRoot(), stepId);
476
+ fs.mkdirSync(dir, { recursive: true });
477
+ const file = path.join(dir, "screenshot.png");
478
+ fs.writeFileSync(file, png);
479
+ traj({ step_id: stepId, event: "result", bytes: png.length });
480
+ return { file, png };
481
+ }
482
+ function shotOutput(file, bytes) {
483
+ return `截图已保存(${bytes} bytes):${file}\n(PNG 已落 run store;上下文只携带路径,不携带像素。)`;
484
+ }
485
+ function pageTextOutput(url, text) {
486
+ return `页面文本(${url}):\n${text.slice(0, 20000)}${text.length > 20000 ? "\n…(截断)" : ""}`;
487
+ }
488
+ function discoveryError() {
489
+ return new Error("本机未找到可用的浏览器(按 Edge/Chrome/Chromium 顺序探测失败)。" +
490
+ "设置 TM_BROWSER_PATH 指向浏览器可执行文件,或改用 tm_webfetch / 用户 MCP 工具。");
491
+ }
492
+ /**
493
+ * M1 (upload_file exfil): the domain allowlist constrains the network
494
+ * DESTINATION but never the local SOURCE — an unguarded filePath could
495
+ * upload .env / key files to a whitelisted host's form. Reuses the read
496
+ * pipeline's P2 containment verbatim (workspace root + blackboard +
497
+ * trajectory; realpath-verified, fail-closed on unresolvable paths) and
498
+ * then applies the R6 env-file classifier, so *.env / shell-rc files are
499
+ * refused INSIDE the root too. The env refusal echoes no path (R6 privacy
500
+ * line); the scope verdict keeps the tm_read/tm_grep message shape.
501
+ */
502
+ function uploadExfilGuard(args, ctx) {
503
+ const raw = (args.filePath ?? args.files);
504
+ const files = (Array.isArray(raw) ? raw : [raw]).map((f) => String(f ?? "").trim()).filter(Boolean);
505
+ if (!files.length)
506
+ return null; // "缺少 filePath" stays act()'s own args error
507
+ const d = ctx?.directory;
508
+ const root = typeof d === "string" && d ? d : process.cwd();
509
+ const extra = [pipelines.store.blackboardRoot, pipelines.store.trajectoryRoot].filter((p) => typeof p === "string" && p.length > 0);
510
+ for (const f of files) {
511
+ if (isEnvFilePath(f))
512
+ return "拒绝上传(R6 红线:.env / shell rc 家族文件永不离开本机)";
513
+ const scope = assertReadablePath(root, f, extra);
514
+ if (!scope.ok)
515
+ return scope.message;
516
+ }
517
+ return null;
518
+ }
519
+ // ================= cdp-legacy engine (原手写 CDP 管道,保留不删) =============
520
+ async function openLegacySession(headless) {
521
+ const executable = findBrowserExecutable(env);
522
+ if (!executable)
523
+ throw discoveryError();
255
524
  const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-browser-"));
256
525
  const child = spawn(executable, [
257
526
  headless ? "--headless=new" : "--start-maximized",
@@ -259,7 +528,7 @@ export function buildTmBrowserTool(deps) {
259
528
  `--user-data-dir=${profileDir}`,
260
529
  // real-Chrome UA even under --headless=new (which some sites detect
261
530
  // 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",
531
+ `--user-agent=${HARDENED_UA}`,
263
532
  "--accept-lang=zh-CN,zh;q=0.9,en;q=0.8",
264
533
  "--no-first-run", "--no-default-browser-check", "--disable-extensions",
265
534
  "--disable-background-networking", "--mute-audio",
@@ -286,7 +555,7 @@ export function buildTmBrowserTool(deps) {
286
555
  const page = targets.targetInfos.find((t) => t.type === "page");
287
556
  if (!page)
288
557
  throw new Error("浏览器启动后未找到 page target");
289
- const { sessionId } = await cdp.call("Target.attachToTarget", { targetId: page.targetId, flatten: true });
558
+ const { sessionId } = (await cdp.call("Target.attachToTarget", { targetId: page.targetId, flatten: true }));
290
559
  await cdp.call("Page.enable", {}, sessionId);
291
560
  // network-layer allowlist enforcement (Qoder's per-hop re-check analog)
292
561
  await cdp.call("Fetch.enable", { patterns: [{ urlPattern: "*" }] }, sessionId);
@@ -314,8 +583,78 @@ export function buildTmBrowserTool(deps) {
314
583
  void cdp.call("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }, sessionId, 5000).catch(() => { });
315
584
  }
316
585
  });
317
- child.on("exit", () => {
318
- session = null;
586
+ const sess = {
587
+ kind: "cdp-legacy",
588
+ currentUrl: "about:blank",
589
+ async act(action, args, stepId) {
590
+ if (action === "navigate" || action === "navigate_page") {
591
+ const url = String(args.url ?? "").trim();
592
+ await cdp.call("Page.navigate", { url }, sessionId);
593
+ await cdp.waitEvent("Page.loadEventFired", sessionId, NAVIGATE_EVENT_TIMEOUT_MS).catch(() => { });
594
+ sess.currentUrl = url;
595
+ return `已导航:${url}`;
596
+ }
597
+ if (action === "read") {
598
+ traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
599
+ const ev = await cdp.call("Runtime.evaluate", { expression: "document.body ? document.body.innerText : ''", returnByValue: true }, sessionId);
600
+ const text = String(ev.result?.value ?? "");
601
+ traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
602
+ return pageTextOutput(sess.currentUrl, text);
603
+ }
604
+ if (action === "screenshot" || action === "take_screenshot") {
605
+ traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
606
+ const shot = await cdp.call("Page.captureScreenshot", { format: "png" }, sessionId);
607
+ const png = Buffer.from(String(shot.data ?? ""), "base64");
608
+ const { file } = saveShotPng(new Uint8Array(png), stepId);
609
+ return shotOutput(file, png.length);
610
+ }
611
+ if (action === "list_pages") {
612
+ const t = await cdp.call("Target.getTargets");
613
+ const pages = t.targetInfos.filter((x) => x.type === "page");
614
+ const lines = pages.map((p, i) => `${i}: ${String(p.url ?? "")} ${String(p.title ?? "")}`.trimEnd());
615
+ return `标签页(${pages.length}):\n${lines.join("\n") || "(无)"}`;
616
+ }
617
+ if (action === "evaluate_script") {
618
+ const src = String(args.function ?? args.expression ?? "").trim();
619
+ if (!src)
620
+ throw new Error('缺少 expression/function 参数(evaluate_script 需要一个 JS 表达式)');
621
+ traj({ step_id: stepId, event: "call", kind: "evaluate" });
622
+ const ev = await cdp.call("Runtime.evaluate", { expression: src, returnByValue: true }, sessionId);
623
+ const value = ev.result?.value;
624
+ return capTokens(`执行结果:${JSON.stringify(value ?? null)}`, "…(结果超长已截断)");
625
+ }
626
+ throw new Error(`action "${action}" 需要 playwright 引擎(当前为 cdp-legacy 降级:playwright-core 未安装或 node<20)。` +
627
+ `cdp-legacy 可用: ${BROWSER_LEGACY_ACTIONS.filter((a) => a !== "open" && a !== "close").join(" | ")}`);
628
+ },
629
+ async close() {
630
+ child.off?.("exit", onExit);
631
+ await cdp.call("Browser.close", {}, undefined, 3000).catch(() => { });
632
+ if (child.exitCode === null) {
633
+ child.kill();
634
+ }
635
+ // wait briefly for the process to release the profile (Windows file
636
+ // locks linger while the browser is still shutting down)
637
+ await new Promise((res) => {
638
+ if (child.exitCode !== null)
639
+ return res();
640
+ const t = setTimeout(res, 3000);
641
+ child.once("exit", () => {
642
+ clearTimeout(t);
643
+ res();
644
+ });
645
+ });
646
+ try {
647
+ rmForceSafe(profileDir, { recursive: true });
648
+ }
649
+ catch {
650
+ /* a locked leftover temp dir is reclaimed by the OS — never fail close */
651
+ }
652
+ return "浏览器会话已关闭,临时配置目录已清理。";
653
+ },
654
+ };
655
+ const onExit = () => {
656
+ if (session === sess)
657
+ session = null;
319
658
  // best-effort: the profile may still be file-locked during shutdown —
320
659
  // a leftover temp dir is reclaimed by the OS, never an error for the task
321
660
  try {
@@ -324,38 +663,359 @@ export function buildTmBrowserTool(deps) {
324
663
  catch {
325
664
  /* ignore */
326
665
  }
327
- });
328
- session = { child, cdp, sessionId, profileDir, currentUrl: "about:blank" };
329
- return session;
666
+ };
667
+ child.on("exit", onExit);
668
+ return sess;
330
669
  }
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();
670
+ // ================= playwright engine (primary) ==============================
671
+ async function openPlaywrightSession(pw, headless) {
672
+ const executable = findBrowserExecutable(env);
673
+ if (!executable)
674
+ throw discoveryError();
675
+ const launchArgs = [
676
+ "--no-first-run", "--no-default-browser-check", "--disable-extensions",
677
+ "--disable-background-networking", "--mute-audio",
678
+ ...(headless ? [] : ["--start-maximized"]),
679
+ ];
680
+ const contextOpts = {
681
+ userAgent: HARDENED_UA,
682
+ locale: "zh-CN",
683
+ extraHTTPHeaders: { "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" },
684
+ // the 3000 ms observation budget is the engine default (see header)
685
+ timeout: PW_DEFAULT_TIMEOUT_MS,
686
+ // headful: honor --start-maximized (null viewport = the OS window size)
687
+ viewport: headless ? undefined : null,
688
+ };
689
+ 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;
694
+ let browser = null;
695
+ let context;
696
+ let via = "executablePath";
697
+ if (persistentDir) {
698
+ context = await pw.chromium.launchPersistentContext(persistentDir, {
699
+ ...contextOpts,
700
+ ...(channel ? { channel } : { executablePath: executable }),
701
+ headless,
702
+ args: launchArgs,
703
+ });
704
+ via = `persistent(${channel ?? "executablePath"})`;
705
+ }
706
+ else {
707
+ try {
708
+ browser = await pw.chromium.launch({ ...(channel ? { channel } : { executablePath: executable }), headless, args: launchArgs });
709
+ via = channel ? `channel(${channel})` : "executablePath";
710
+ }
711
+ catch (e1) {
712
+ if (!channel)
713
+ throw e1;
714
+ browser = await pw.chromium.launch({ executablePath: executable, headless, args: launchArgs });
715
+ via = "executablePath(channel 回退)";
716
+ }
717
+ context = await browser.newContext(contextOpts);
339
718
  }
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();
719
+ const snapIndex = new SnapshotIndex();
720
+ const state = {
721
+ page: context.pages()[0] ?? (await context.newPage()),
722
+ dialogs: [],
723
+ consoleBuf: [],
724
+ netBuf: [],
725
+ };
726
+ const attached = new WeakSet();
727
+ const attach = (p) => {
728
+ if (attached.has(p))
729
+ return; // select_page may bounce back to an already-hooked tab
730
+ attached.add(p);
731
+ p.on("console", (msg) => {
732
+ state.consoleBuf.push({ type: String(msg?.type?.() ?? "log"), text: String(msg?.text?.() ?? "") });
733
+ if (state.consoleBuf.length > OBSERVE_BUFFER_CAP)
734
+ state.consoleBuf.shift();
349
735
  });
350
- });
736
+ p.on("pageerror", (err) => {
737
+ state.consoleBuf.push({ type: "pageerror", text: String(err?.message ?? err) });
738
+ if (state.consoleBuf.length > OBSERVE_BUFFER_CAP)
739
+ state.consoleBuf.shift();
740
+ });
741
+ p.on("request", (req) => {
742
+ state.netBuf.push({ method: req?.method?.() ?? "?", url: String(req?.url?.() ?? "").slice(0, 300) });
743
+ if (state.netBuf.length > OBSERVE_BUFFER_CAP)
744
+ state.netBuf.shift();
745
+ });
746
+ p.on("response", (res) => {
747
+ state.netBuf.push({
748
+ method: res?.request?.()?.method?.() ?? "?",
749
+ url: String(res?.url?.() ?? "").slice(0, 300),
750
+ status: Number(res?.status?.() ?? 0) || undefined,
751
+ });
752
+ if (state.netBuf.length > OBSERVE_BUFFER_CAP)
753
+ state.netBuf.shift();
754
+ });
755
+ p.on("requestfailed", (req) => {
756
+ state.netBuf.push({ method: req?.method?.() ?? "?", url: String(req?.url?.() ?? "").slice(0, 300), failure: "requestfailed" });
757
+ if (state.netBuf.length > OBSERVE_BUFFER_CAP)
758
+ state.netBuf.shift();
759
+ });
760
+ // dialogs are HELD for handle_dialog (registering the handler opts us
761
+ // out of playwright's implicit auto-dismiss)
762
+ p.on("dialog", (d) => {
763
+ state.dialogs.push(d);
764
+ if (state.dialogs.length > 8)
765
+ state.dialogs.shift();
766
+ });
767
+ };
768
+ attach(state.page);
769
+ // ---- network-layer allowlist: context.route() (CDP Fetch 的迁移面) ----
770
+ const routeHandler = async (route, request) => {
771
+ const u = String(request.url?.() ?? "");
772
+ // data:/blob:/about: never reach playwright's route anyway; keep the
773
+ // non-http hard-reject semantics identical to the CDP-era behavior
774
+ if (/^(about|data|blob):/i.test(u)) {
775
+ await route.continue().catch(() => { });
776
+ return;
777
+ }
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
+ }
788
+ if (pass) {
789
+ await route.continue().catch(() => { });
790
+ }
791
+ else {
792
+ // per-hop re-check lands here for every redirect hop too
793
+ traj({ step_id: "browser", event: "blocked", url: u.slice(0, 200) });
794
+ state.netBuf.push({ method: request.method?.() ?? "?", url: u.slice(0, 300), blocked: true });
795
+ await route.abort().catch(() => { });
796
+ }
797
+ };
798
+ // M2 (fix round) fail-closed: the route gate is playwright's analog of
799
+ // the CDP leg's AWAITED Fetch.enable — if registration fails the session
800
+ // would browse with NO network whitelist. Close the context (persistent:
801
+ // the only handle; launched: plus the browser) and surface an execute
802
+ // error; a swallowed .catch(()=>{}) was a fail-open hole.
351
803
  try {
352
- rmForceSafe(profileDir, { recursive: true });
804
+ await Promise.resolve(context.route("**/*", routeHandler));
353
805
  }
354
- catch {
355
- /* a locked leftover temp dir is reclaimed by the OS — never fail close */
806
+ catch (e) {
807
+ await Promise.resolve(context.close()).catch(() => { });
808
+ if (browser)
809
+ await Promise.resolve(browser.close()).catch(() => { });
810
+ traj({ step_id: "browser", event: "gate_register_failed", reason: String(e?.message ?? e).slice(0, 200) });
811
+ throw new Error(`网络白名单闸注册失败(context.route)——已 fail-closed 关闭浏览器会话,不会留下无白名单的窗口。` +
812
+ `原因:${String(e?.message ?? e)}`);
356
813
  }
357
- return "浏览器会话已关闭,临时配置目录已清理。";
814
+ /** uid → locator with the snapshot-first guidance errors (R2: 禁猜
815
+ * locator — only uid addressing or an explicit selector escape). */
816
+ const targetOf = (uidKey, selKey, args) => {
817
+ const uid = String(args[uidKey] ?? "").trim();
818
+ if (uid) {
819
+ const loc = snapIndex.locatorFor(state.page, uid);
820
+ if (!loc) {
821
+ throw new Error(`uid "${uid}" 不在当前快照中(快照会重新编号)——先 action:"take_snapshot" 再用返回的 [uid=...] 寻址`);
822
+ }
823
+ return loc;
824
+ }
825
+ const sel = String(args[selKey] ?? "").trim();
826
+ if (sel)
827
+ return state.page.locator(sel);
828
+ throw new Error(`缺少 ${uidKey}/selector——tm_browser 快照优先:先 action:"take_snapshot",动作只按快照里的 [uid=...] 寻址(或显式 selector 逃生舱),禁止猜 locator`);
829
+ };
830
+ /** Human-readable addressing echo for action results (no full locators
831
+ * into context — uid tokens only). */
832
+ const describeTarget = (args, uidKey = "uid") => {
833
+ const uid = String(args[uidKey] ?? "").trim();
834
+ if (uid)
835
+ return `uid "${uid}"`;
836
+ const sel = String(args[uidKey === "uid" ? "selector" : "targetSelector"] ?? "").trim();
837
+ return sel ? `selector "${sel.slice(0, 60)}"` : "目标";
838
+ };
839
+ const sess = {
840
+ kind: "playwright",
841
+ currentUrl: "about:blank",
842
+ async act(action, args, stepId) {
843
+ if (action === "navigate" || action === "navigate_page") {
844
+ const url = String(args.url ?? "").trim();
845
+ // domcontentloaded ONLY — networkidle is banned (prompt discipline)
846
+ await state.page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
847
+ sess.currentUrl = state.page.url?.() || url;
848
+ return `已导航:${url}`;
849
+ }
850
+ if (action === "read") {
851
+ traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
852
+ const text = String((await state.page.evaluate("document.body ? document.body.innerText : ''")) ?? "");
853
+ traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
854
+ return pageTextOutput(sess.currentUrl, text);
855
+ }
856
+ if (action === "screenshot" || action === "take_screenshot") {
857
+ traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
858
+ const bytes = await state.page.screenshot({ type: "png", fullPage: Boolean(args.fullPage) });
859
+ const { file, png } = saveShotPng(bytes, stepId);
860
+ return shotOutput(file, png.length);
861
+ }
862
+ if (action === "take_snapshot") {
863
+ traj({ step_id: stepId, event: "call", kind: "snapshot", url: sess.currentUrl.slice(0, 200) });
864
+ let yaml;
865
+ try {
866
+ yaml = await state.page.locator("body").ariaSnapshot();
867
+ }
868
+ catch (e) {
869
+ throw new Error(`ariaSnapshot 不可用(playwright-core 版本过旧或缺失):${e.message} —— 升级 playwright-core(需 ≥1.65 才有 locator.ariaSnapshot)`);
870
+ }
871
+ const annotated = snapIndex.annotate(yaml);
872
+ traj({ step_id: stepId, event: "result", tokens: estimateTokens(annotated), nodes: snapIndex.size });
873
+ const body = capTokens(annotated, `…(快照超过 browserSnapshotMaxTokens=${snapshotBudget},后续行已截断——用 read 拿原文或先滚动再快照)`);
874
+ const hint = state.dialogs.length ? `\n注意:有 ${state.dialogs.length} 个未处理对话框——先 handle_dialog` : "";
875
+ return `ARIA 快照(${sess.currentUrl} · ${snapIndex.size} 个可寻址节点)——后续动作按行内 [uid=eN] 寻址:\n${body}${hint}`;
876
+ }
877
+ if (action === "click") {
878
+ const loc = targetOf("uid", "selector", args);
879
+ await loc.click();
880
+ return `已点击 ${describeTarget(args)}${state.dialogs.length ? ";有未处理对话框——handle_dialog(同轮观察,勿另起动作)" : ""}`;
881
+ }
882
+ if (action === "fill") {
883
+ const loc = targetOf("uid", "selector", args);
884
+ const text = String(args.text ?? "");
885
+ await loc.fill(text);
886
+ return `已填充 ${describeTarget(args)}(${text.length} 字符)`;
887
+ }
888
+ if (action === "hover") {
889
+ const loc = targetOf("uid", "selector", args);
890
+ await loc.hover();
891
+ return `已悬停 ${describeTarget(args)}`;
892
+ }
893
+ if (action === "drag") {
894
+ const from = targetOf("uid", "selector", args);
895
+ const to = targetOf("targetUid", "targetSelector", args);
896
+ await from.dragTo(to);
897
+ return `已拖拽 ${describeTarget(args, "uid")} → ${describeTarget(args, "targetUid")}`;
898
+ }
899
+ if (action === "press_key") {
900
+ const key = String(args.key ?? "").trim();
901
+ if (!key)
902
+ throw new Error('缺少 key 参数(如 "Enter"、"Control+A")');
903
+ await state.page.keyboard.press(key);
904
+ return `已按键:${key}`;
905
+ }
906
+ if (action === "upload_file") {
907
+ const loc = targetOf("uid", "selector", args);
908
+ const raw = args.filePath ?? args.files;
909
+ const files = (Array.isArray(raw) ? raw : [raw]).map((f) => String(f ?? "").trim()).filter(Boolean);
910
+ if (!files.length)
911
+ throw new Error("缺少 filePath 参数(文件或文件数组,本机路径)");
912
+ for (const f of files) {
913
+ if (!fs.existsSync(f))
914
+ throw new Error(`上传文件不存在:${f.slice(0, 200)}`);
915
+ }
916
+ await loc.setInputFiles(files);
917
+ return `已上传 ${files.length} 个文件到 ${describeTarget(args)}`;
918
+ }
919
+ if (action === "wait_for") {
920
+ const uid = String(args.uid ?? "").trim();
921
+ const text = String(args.text ?? "").trim();
922
+ const timeout = Math.min(30_000, Math.max(100, Number(args.timeoutMs) || PW_DEFAULT_TIMEOUT_MS));
923
+ const what = uid ? `uid "${uid}"` : text ? `文本 "${text.slice(0, 80)}"` : "";
924
+ if (!uid && !text)
925
+ throw new Error('wait_for 需要 text 或 uid');
926
+ const loc = uid ? targetOf("uid", "selector", { uid }) : state.page.getByText(text);
927
+ await loc.waitFor({ state: "visible", timeout });
928
+ return `等待命中:${what}(≤${timeout}ms 内可见)`;
929
+ }
930
+ if (action === "evaluate_script") {
931
+ const src = String(args.function ?? args.expression ?? "").trim();
932
+ if (!src)
933
+ throw new Error('缺少 expression/function 参数(JS 函数源或表达式)');
934
+ traj({ step_id: stepId, event: "call", kind: "evaluate" });
935
+ const value = await state.page.evaluate(src);
936
+ return capTokens(`执行结果:${JSON.stringify(value ?? null)}`, "…(结果超长已截断)");
937
+ }
938
+ if (action === "list_pages") {
939
+ const pages = context.pages();
940
+ const lines = [];
941
+ for (let i = 0; i < pages.length; i++) {
942
+ const p = pages[i];
943
+ const title = await Promise.resolve(p.title?.()).catch(() => "");
944
+ lines.push(`${i}${p === state.page ? "(当前)" : ""}: ${String(p.url?.() ?? "")} ${String(title ?? "")}`.trimEnd());
945
+ }
946
+ return `标签页(${pages.length}):\n${lines.join("\n") || "(无)"}\n用 select_page { index } 切换。`;
947
+ }
948
+ if (action === "select_page") {
949
+ const idx = Number(args.index);
950
+ const pages = context.pages();
951
+ if (!Number.isInteger(idx) || idx < 0 || idx >= pages.length) {
952
+ throw new Error(`index 越界(0..${pages.length - 1})——先 list_pages`);
953
+ }
954
+ state.page = pages[idx];
955
+ attach(state.page);
956
+ await Promise.resolve(state.page.bringToFront?.()).catch(() => { });
957
+ sess.currentUrl = String(state.page.url?.() ?? "");
958
+ return `已切换到标签页 ${idx}:${sess.currentUrl}`;
959
+ }
960
+ if (action === "list_console_messages") {
961
+ const lines = state.consoleBuf.map((m, i) => `${i + 1}. [${m.type}] ${m.text.slice(0, 300)}`);
962
+ if (args.clear)
963
+ state.consoleBuf.length = 0;
964
+ return capTokens(`控制台消息(缓冲 ${lines.length}/${OBSERVE_BUFFER_CAP}):\n${lines.join("\n") || "(无)"}`, "…(消息超长已截断)");
965
+ }
966
+ if (action === "list_network_requests") {
967
+ const lines = state.netBuf.map((r, i) => `${i + 1}. ${r.method} ${r.url}${r.status !== undefined ? ` → ${r.status}` : ""}${r.blocked ? " [被白名单拦截]" : r.failure ? ` [${r.failure}]` : ""}`);
968
+ return capTokens(`网络请求(缓冲 ${lines.length}/${OBSERVE_BUFFER_CAP}):\n${lines.join("\n") || "(无)"}`, "…(列表超长已截断)");
969
+ }
970
+ if (action === "handle_dialog") {
971
+ const d = state.dialogs.shift();
972
+ if (!d)
973
+ return "没有待处理的对话框(弹窗会在 click/submit 后出现——出现时 take_snapshot 的头部会提示,再 handle_dialog)。";
974
+ const verdict = String(args.dialogAction ?? "accept").trim().toLowerCase();
975
+ if (verdict === "dismiss") {
976
+ await d.dismiss();
977
+ }
978
+ else {
979
+ await d.accept(typeof args.promptText === "string" ? args.promptText : undefined);
980
+ }
981
+ return `已处理对话框(${d.type()}:「${d.message().slice(0, 120)}」→ ${verdict === "dismiss" ? "dismiss" : "accept"})`;
982
+ }
983
+ throw new Error(`playwright 引擎未知 action "${action}"`);
984
+ },
985
+ async close() {
986
+ state.dialogs.length = 0;
987
+ await Promise.resolve(context.close()).catch(() => { });
988
+ if (browser)
989
+ await Promise.resolve(browser.close()).catch(() => { });
990
+ if (persistentDir) {
991
+ return `浏览器会话已关闭;持久配置目录已保留(下次 open 复用登录态):${persistentDir}`;
992
+ }
993
+ // playwright owns (and self-reclaims) the ephemeral profile under
994
+ // the browser process — nothing on our side to delete
995
+ return "浏览器会话已关闭,临时配置目录已清理。";
996
+ },
997
+ };
998
+ return sess;
999
+ }
1000
+ // ---------- engine-neutral session lifecycle ----------
1001
+ async function ensureSession(headless) {
1002
+ if (session)
1003
+ return session;
1004
+ const sel = await selection();
1005
+ session =
1006
+ sel.kind === "playwright" && sel.pw
1007
+ ? await openPlaywrightSession(sel.pw, headless)
1008
+ : await openLegacySession(headless);
1009
+ return session;
1010
+ }
1011
+ async function closeActive() {
1012
+ const s = session;
1013
+ if (!s)
1014
+ return "没有打开的浏览器会话。";
1015
+ session = null;
1016
+ return s.close();
358
1017
  }
1018
+ const ALL_ACTIONS = new Set([...BROWSER_PLAYWRIGHT_ACTIONS, ...BROWSER_COMPAT_ACTIONS, "navigate_page"]);
359
1019
  const execute = async (rawArgs, ctx) => {
360
1020
  try {
361
1021
  const args = rawArgs ?? {};
@@ -363,8 +1023,10 @@ export function buildTmBrowserTool(deps) {
363
1023
  const url = String(args.url ?? "").trim();
364
1024
  // allowlist FIRST — an out-of-allowlist URL only proceeds after the
365
1025
  // 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) {
1026
+ // too); hard red lines (scheme / env-file) reject with no dialog.
1027
+ // NOTE: this gate runs BEFORE engine selection — a blocked target
1028
+ // never touches playwright nor spawns anything (§6o invariant).
1029
+ if ((action === "open" || action === "navigate" || action === "navigate_page") && url) {
368
1030
  const verdict = checkWebUrl(url, allowlist);
369
1031
  if (!verdict.ok) {
370
1032
  if (!verdict.askable || !verdict.url) {
@@ -387,80 +1049,88 @@ export function buildTmBrowserTool(deps) {
387
1049
  return toToolResult(tmError(tool, "args", "缺少 url 参数"));
388
1050
  if (session) {
389
1051
  // already open: navigate instead of spawning a second browser
390
- return toToolResult(await navigateTo(url));
1052
+ return toToolResult(await session.act("navigate", { url }, "browser"));
391
1053
  }
392
1054
  const headless = args.headless != null ? Boolean(args.headless) : resolveHeadless(env);
393
1055
  const s = await ensureSession(headless);
394
- const out = await navigateTo(url);
1056
+ const out = await s.act("navigate", { url }, "browser");
395
1057
  traj({ step_id: "browser", event: "open", headless });
396
- return toToolResult(`浏览器已启动(${headless ? "无头" : "有头窗口"},隔离临时配置)。${out}`);
1058
+ const mode = headless ? "无头" : "有头窗口";
1059
+ const profile = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim() ? "持久登录配置" : "隔离临时配置";
1060
+ const note = lastSel && preference !== "cdp-legacy" && lastSel.kind === "cdp-legacy"
1061
+ ? `\n(引擎:cdp-legacy 降级——${lastSel.reason ?? ""};完整 16 动作需 npm install playwright-core + node≥20)`
1062
+ : "";
1063
+ return toToolResult(`浏览器已启动(${mode},${profile})。${out}${note}`);
397
1064
  }
398
- if (action === "navigate") {
1065
+ if (action === "navigate" || action === "navigate_page") {
399
1066
  if (!url)
400
1067
  return toToolResult(tmError(tool, "args", "缺少 url 参数"));
401
1068
  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;上下文只携带路径,不携带像素。)`);
1069
+ return toToolResult(tmError(tool, "args", '没有打开的浏览器会话——先用 action:"open"'));
1070
+ return toToolResult(await session.act(action, { url }, "browser"));
429
1071
  }
430
1072
  if (action === "close") {
431
1073
  traj({ step_id: "browser", event: "close" });
432
- return toToolResult(await closeSession());
1074
+ return toToolResult(await closeActive());
1075
+ }
1076
+ if (!ALL_ACTIONS.has(action)) {
1077
+ 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=…] 寻址)`));
433
1078
  }
434
- return toToolResult(tmError(tool, "args", `未知 action "${String(action).slice(0, 30)}"——可用: open | navigate | read | screenshot | close`));
1079
+ if (!session)
1080
+ return toToolResult(tmError(tool, "args", '没有打开的浏览器会话——先用 action:"open"'));
1081
+ // M1: upload_file SOURCE containment runs at the execute layer — the
1082
+ // engine act() has no host ctx. Out-of-P2 paths and R6 env files are
1083
+ // refused before setInputFiles can move any bytes.
1084
+ if (action === "upload_file") {
1085
+ const refusal = uploadExfilGuard(args, ctx);
1086
+ if (refusal) {
1087
+ traj({ step_id: "browser", event: "upload_refused", reason: refusal.slice(0, 200) });
1088
+ return toToolResult(tmError(tool, "permission", refusal));
1089
+ }
1090
+ }
1091
+ const stepId = pipelines.nextStepId();
1092
+ return toToolResult(await session.act(action, args, stepId));
435
1093
  }
436
1094
  catch (err) {
437
1095
  const e = err;
438
1096
  return toToolResult(tmError(tool, "execute", String(e?.message ?? err ?? "browser 操作失败")));
439
1097
  }
440
1098
  };
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.`;
1099
+ 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.
1102
+ - 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.
1104
+ - 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
1105
  return {
454
1106
  description: DESCRIPTION,
455
1107
  args: deps.args ?? {
456
- action: { descriptor: "action: open|navigate|read|screenshot|close (required)" },
457
- url: { descriptor: "url: string (open/navigate, allowlisted https)" },
1108
+ action: { descriptor: "action: take_snapshot|click|fill|navigate_page|... (16 playwright verbs + open/navigate/read/screenshot/close)" },
1109
+ url: { descriptor: "url: string (open/navigate/navigate_page, allowlisted https)" },
458
1110
  headless: { descriptor: "headless: boolean (open, optional — default auto)" },
1111
+ uid: { descriptor: "uid: snapshot [uid=eN] token (click/fill/hover/drag/upload_file/wait_for)" },
1112
+ selector: { descriptor: "selector: CSS/text locator escape hatch (only when a snapshot cannot express the node)" },
1113
+ targetUid: { descriptor: "targetUid: drag destination uid" },
1114
+ targetSelector: { descriptor: "targetSelector: drag destination selector" },
1115
+ text: { descriptor: "text: fill value / wait_for visible text" },
1116
+ key: { descriptor: "key: press_key chord, e.g. Enter|Control+A" },
1117
+ function: { descriptor: "function: JS function source for evaluate_script" },
1118
+ filePath: { descriptor: "filePath: local file path(s) for upload_file" },
1119
+ index: { descriptor: "index: select_page target from list_pages" },
1120
+ timeoutMs: { descriptor: "timeoutMs: wait_for budget (default 3000)" },
1121
+ fullPage: { descriptor: "fullPage: take_screenshot full page (default viewport only)" },
1122
+ dialogAction: { descriptor: "dialogAction: accept|dismiss for handle_dialog (default accept)" },
1123
+ promptText: { descriptor: "promptText: prompt-dialog reply for handle_dialog accept" },
1124
+ clear: { descriptor: "clear: drain the console buffer after list_console_messages" },
459
1125
  },
460
1126
  execute,
461
1127
  dispose: () => {
462
- void closeSession().catch(() => { });
1128
+ void closeActive().catch(() => { });
463
1129
  },
1130
+ engineInfo: () => ({
1131
+ kind: lastSel?.kind ?? null,
1132
+ reason: lastSel?.reason ?? null,
1133
+ }),
464
1134
  };
465
1135
  }
466
1136
  //# sourceMappingURL=browser.js.map