@te-river/opencode-team-mode 1.5.10 → 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.
- package/README.md +143 -56
- package/README.zh-CN.md +116 -47
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +17 -11
- package/dist/agents.js.map +1 -1
- package/dist/approval-gate.d.ts +54 -16
- package/dist/approval-gate.d.ts.map +1 -1
- package/dist/approval-gate.js +163 -42
- package/dist/approval-gate.js.map +1 -1
- package/dist/prompts/lead.d.ts +1 -1
- package/dist/prompts/lead.d.ts.map +1 -1
- package/dist/prompts/lead.js +22 -3
- package/dist/prompts/lead.js.map +1 -1
- package/dist/prompts/shared.d.ts +1 -1
- package/dist/prompts/shared.d.ts.map +1 -1
- package/dist/prompts/shared.js +154 -119
- package/dist/prompts/shared.js.map +1 -1
- package/dist/prompts/specialists.d.ts +2 -2
- package/dist/prompts/specialists.d.ts.map +1 -1
- package/dist/prompts/specialists.js +40 -11
- package/dist/prompts/specialists.js.map +1 -1
- package/dist/tm/args-schema.d.ts +25 -1
- package/dist/tm/args-schema.d.ts.map +1 -1
- package/dist/tm/args-schema.js +149 -39
- package/dist/tm/args-schema.js.map +1 -1
- package/dist/tm/browser.d.ts +203 -18
- package/dist/tm/browser.d.ts.map +1 -1
- package/dist/tm/browser.js +853 -111
- package/dist/tm/browser.js.map +1 -1
- package/dist/tm/config.d.ts +61 -0
- package/dist/tm/config.d.ts.map +1 -1
- package/dist/tm/config.js +55 -1
- package/dist/tm/config.js.map +1 -1
- package/dist/tm/index.d.ts +7 -5
- package/dist/tm/index.d.ts.map +1 -1
- package/dist/tm/index.js +38 -4
- package/dist/tm/index.js.map +1 -1
- package/dist/tm/memory.d.ts +88 -10
- package/dist/tm/memory.d.ts.map +1 -1
- package/dist/tm/memory.js +881 -99
- package/dist/tm/memory.js.map +1 -1
- package/dist/tm/pipelines.d.ts +26 -3
- package/dist/tm/pipelines.d.ts.map +1 -1
- package/dist/tm/pipelines.js +167 -11
- package/dist/tm/pipelines.js.map +1 -1
- package/dist/tm/ptc/budgets.d.ts +22 -1
- package/dist/tm/ptc/budgets.d.ts.map +1 -1
- package/dist/tm/ptc/budgets.js +87 -8
- package/dist/tm/ptc/budgets.js.map +1 -1
- package/dist/tm/ptc/contract.d.ts +49 -9
- package/dist/tm/ptc/contract.d.ts.map +1 -1
- package/dist/tm/ptc/contract.js +41 -3
- package/dist/tm/ptc/contract.js.map +1 -1
- package/dist/tm/ptc/driver.d.ts +5 -2
- package/dist/tm/ptc/driver.d.ts.map +1 -1
- package/dist/tm/ptc/driver.js +47 -15
- package/dist/tm/ptc/driver.js.map +1 -1
- package/dist/tm/ptc/engines.d.ts +15 -4
- package/dist/tm/ptc/engines.d.ts.map +1 -1
- package/dist/tm/ptc/engines.js +281 -28
- package/dist/tm/ptc/engines.js.map +1 -1
- package/dist/tm/ptc/index.d.ts +4 -4
- package/dist/tm/ptc/index.d.ts.map +1 -1
- package/dist/tm/ptc/index.js +2 -2
- package/dist/tm/ptc/index.js.map +1 -1
- package/dist/tm/ptc/pscan.d.ts +20 -1
- package/dist/tm/ptc/pscan.d.ts.map +1 -1
- package/dist/tm/ptc/pscan.js +328 -14
- package/dist/tm/ptc/pscan.js.map +1 -1
- package/dist/tm/ptc/summary.d.ts.map +1 -1
- package/dist/tm/ptc/summary.js +23 -1
- package/dist/tm/ptc/summary.js.map +1 -1
- package/dist/tm/ptc/tool.d.ts +34 -3
- package/dist/tm/ptc/tool.d.ts.map +1 -1
- package/dist/tm/ptc/tool.js +117 -20
- package/dist/tm/ptc/tool.js.map +1 -1
- package/dist/tm/search.d.ts +83 -14
- package/dist/tm/search.d.ts.map +1 -1
- package/dist/tm/search.js +440 -45
- package/dist/tm/search.js.map +1 -1
- package/dist/tm/shell-bridge.d.ts +14 -1
- package/dist/tm/shell-bridge.d.ts.map +1 -1
- package/dist/tm/shell-bridge.js +57 -10
- package/dist/tm/shell-bridge.js.map +1 -1
- package/dist/tm/webfetch.d.ts +33 -8
- package/dist/tm/webfetch.d.ts.map +1 -1
- package/dist/tm/webfetch.js +131 -20
- package/dist/tm/webfetch.js.map +1 -1
- package/package.json +5 -2
package/dist/tm/browser.js
CHANGED
|
@@ -1,36 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tm_browser — governed interactive browser (Plan C: the user's own
|
|
3
|
-
* Chromium-family browser
|
|
3
|
+
* Chromium-family browser, headful by default on desktops).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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.
|
|
24
55
|
*
|
|
25
|
-
* 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).
|
|
26
58
|
*/
|
|
27
|
-
import { spawn } from "node:child_process";
|
|
59
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
28
60
|
import * as fs from "node:fs";
|
|
29
61
|
import * as os from "node:os";
|
|
30
62
|
import * as path from "node:path";
|
|
31
|
-
import { checkWebUrl } from "./webfetch.js";
|
|
63
|
+
import { checkWebUrl, seedWebfetchDomains } from "./webfetch.js";
|
|
32
64
|
import { askUserForTarget } from "./perm-ask.js";
|
|
65
|
+
import { assertReadablePath } from "./guard.js";
|
|
66
|
+
import { isEnvFilePath } from "../envprotect.js";
|
|
33
67
|
import { tmError, toToolResult } from "./result.js";
|
|
68
|
+
import { estimateTokens } from "./config.js";
|
|
34
69
|
import { rmForceSafe } from "../fs-safe.js";
|
|
35
70
|
/** Per-OS browser candidates, in preference order (first hit wins). */
|
|
36
71
|
const BROWSER_CANDIDATES = {
|
|
@@ -54,8 +89,77 @@ const BROWSER_CANDIDATES = {
|
|
|
54
89
|
"/snap/bin/chromium",
|
|
55
90
|
],
|
|
56
91
|
};
|
|
57
|
-
|
|
58
|
-
|
|
92
|
+
const defaultRunner = (cmd, args) => String(execFileSync(cmd, args, { encoding: "utf8", timeout: 3000, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }));
|
|
93
|
+
/** Chromium-family binaries only — --remote-debugging-pipe is a Chromium
|
|
94
|
+
* protocol; Firefox/WebKit as the default browser cannot drive tm_browser. */
|
|
95
|
+
const CHROMIUM_FAMILY_RE = /(?:^|[\\/])(?:chrome|msedge|brave|vivaldi|chromium|chromium-browser|opera|yandex)(?:\.exe)?$/i;
|
|
96
|
+
export function isChromiumFamily(exePath) {
|
|
97
|
+
return CHROMIUM_FAMILY_RE.test(String(exePath ?? "").trim());
|
|
98
|
+
}
|
|
99
|
+
/** Parse `reg query "...UrlAssociations\http\UserChoice" /v ProgId` output. */
|
|
100
|
+
export function parseProgId(stdout) {
|
|
101
|
+
const m = /ProgId\s+REG_SZ\s+(\S+)/i.exec(String(stdout ?? ""));
|
|
102
|
+
return m ? m[1] : null;
|
|
103
|
+
}
|
|
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. */
|
|
107
|
+
export function parseRegCommand(stdout) {
|
|
108
|
+
const m = /REG_SZ\s+(?:"([^"]+)"|(\S+))/i.exec(String(stdout ?? ""));
|
|
109
|
+
return m ? (m[1] ?? m[2]) : null;
|
|
110
|
+
}
|
|
111
|
+
/** Parse the Exec= line of an xdg .desktop file. */
|
|
112
|
+
export function parseDesktopExec(text) {
|
|
113
|
+
const m = /^Exec\s*=\s*(?:"([^"]+)"|(\S+))/m.exec(String(text ?? ""));
|
|
114
|
+
return m ? (m[1] ?? m[2]) : null;
|
|
115
|
+
}
|
|
116
|
+
/** The user's DEFAULT browser, when it is Chromium-family and present.
|
|
117
|
+
* null → the caller falls back to the per-OS probe list (detection is
|
|
118
|
+
* best-effort and must never throw into tool discovery). */
|
|
119
|
+
export function defaultBrowserExecutable(env = process.env, run = defaultRunner) {
|
|
120
|
+
try {
|
|
121
|
+
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;
|
|
134
|
+
}
|
|
135
|
+
if (process.platform === "linux") {
|
|
136
|
+
const desk = run("xdg-settings", ["get", "default-web-browser"]).trim();
|
|
137
|
+
if (!desk)
|
|
138
|
+
return null;
|
|
139
|
+
const name = desk.endsWith(".desktop") ? desk : desk + ".desktop";
|
|
140
|
+
const dirs = [
|
|
141
|
+
"/usr/share/applications",
|
|
142
|
+
"/usr/local/share/applications",
|
|
143
|
+
path.join(String(env.HOME ?? ""), ".local/share/applications"),
|
|
144
|
+
];
|
|
145
|
+
for (const dir of dirs) {
|
|
146
|
+
const f = path.join(dir, name);
|
|
147
|
+
if (!fs.existsSync(f))
|
|
148
|
+
continue;
|
|
149
|
+
const exe = parseDesktopExec(fs.readFileSync(f, "utf8"));
|
|
150
|
+
if (exe && isChromiumFamily(exe) && fs.existsSync(exe))
|
|
151
|
+
return exe;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
/* best-effort — the probe list takes over */
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
/** Resolve the browser executable: TM_BROWSER_PATH override → the user's
|
|
161
|
+
* DEFAULT browser (Chromium-family only) → per-OS candidate paths.
|
|
162
|
+
* %LOCALAPPDATA% style vars are expanded on win32. */
|
|
59
163
|
export function findBrowserExecutable(env = process.env) {
|
|
60
164
|
const override = String(env.TM_BROWSER_PATH ?? "").trim();
|
|
61
165
|
const tryPath = (p) => {
|
|
@@ -69,6 +173,9 @@ export function findBrowserExecutable(env = process.env) {
|
|
|
69
173
|
};
|
|
70
174
|
if (override)
|
|
71
175
|
return tryPath(override);
|
|
176
|
+
const def = defaultBrowserExecutable(env);
|
|
177
|
+
if (def)
|
|
178
|
+
return def;
|
|
72
179
|
for (const c of BROWSER_CANDIDATES[process.platform] ?? []) {
|
|
73
180
|
const hit = tryPath(c);
|
|
74
181
|
if (hit)
|
|
@@ -99,7 +206,7 @@ class PipeCdp {
|
|
|
99
206
|
pending = new Map();
|
|
100
207
|
buf = Buffer.alloc(0);
|
|
101
208
|
events = [];
|
|
102
|
-
onEvent = (handler) => this.eventHandler = handler;
|
|
209
|
+
onEvent = (handler) => (this.eventHandler = handler);
|
|
103
210
|
eventHandler = () => { };
|
|
104
211
|
constructor(child) {
|
|
105
212
|
this.child = child;
|
|
@@ -158,28 +265,262 @@ class PipeCdp {
|
|
|
158
265
|
}
|
|
159
266
|
}
|
|
160
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";
|
|
161
423
|
const SPAWN_TIMEOUT_MS = 15_000;
|
|
162
424
|
const NAVIGATE_EVENT_TIMEOUT_MS = 15_000;
|
|
163
|
-
// ---------- tool ----------------------------------------------------------------
|
|
164
425
|
export function buildTmBrowserTool(deps) {
|
|
165
426
|
const { pipelines } = deps;
|
|
166
427
|
const env = deps.env ?? process.env;
|
|
167
428
|
const tool = "tm_browser";
|
|
168
429
|
const traj = (e) => pipelines.store.appendTrajectory({ tool, ...e });
|
|
169
430
|
let session = null;
|
|
170
|
-
|
|
171
|
-
|
|
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 ?? ["*"]);
|
|
172
436
|
// hosts approved through the OFFICIAL dialog this plugin lifetime — the
|
|
173
|
-
//
|
|
437
|
+
// network layer (both engines) consults this in addition to the static allowlist
|
|
174
438
|
const approvedHosts = new Set();
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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;
|
|
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;
|
|
182
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();
|
|
183
524
|
const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-browser-"));
|
|
184
525
|
const child = spawn(executable, [
|
|
185
526
|
headless ? "--headless=new" : "--start-maximized",
|
|
@@ -187,7 +528,7 @@ export function buildTmBrowserTool(deps) {
|
|
|
187
528
|
`--user-data-dir=${profileDir}`,
|
|
188
529
|
// real-Chrome UA even under --headless=new (which some sites detect
|
|
189
530
|
// via the "HeadlessChrome" token otherwise) + zh-CN Accept-Language
|
|
190
|
-
|
|
531
|
+
`--user-agent=${HARDENED_UA}`,
|
|
191
532
|
"--accept-lang=zh-CN,zh;q=0.9,en;q=0.8",
|
|
192
533
|
"--no-first-run", "--no-default-browser-check", "--disable-extensions",
|
|
193
534
|
"--disable-background-networking", "--mute-audio",
|
|
@@ -214,7 +555,7 @@ export function buildTmBrowserTool(deps) {
|
|
|
214
555
|
const page = targets.targetInfos.find((t) => t.type === "page");
|
|
215
556
|
if (!page)
|
|
216
557
|
throw new Error("浏览器启动后未找到 page target");
|
|
217
|
-
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 }));
|
|
218
559
|
await cdp.call("Page.enable", {}, sessionId);
|
|
219
560
|
// network-layer allowlist enforcement (Qoder's per-hop re-check analog)
|
|
220
561
|
await cdp.call("Fetch.enable", { patterns: [{ urlPattern: "*" }] }, sessionId);
|
|
@@ -242,8 +583,78 @@ export function buildTmBrowserTool(deps) {
|
|
|
242
583
|
void cdp.call("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }, sessionId, 5000).catch(() => { });
|
|
243
584
|
}
|
|
244
585
|
});
|
|
245
|
-
|
|
246
|
-
|
|
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;
|
|
247
658
|
// best-effort: the profile may still be file-locked during shutdown —
|
|
248
659
|
// a leftover temp dir is reclaimed by the OS, never an error for the task
|
|
249
660
|
try {
|
|
@@ -252,38 +663,359 @@ export function buildTmBrowserTool(deps) {
|
|
|
252
663
|
catch {
|
|
253
664
|
/* ignore */
|
|
254
665
|
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return
|
|
666
|
+
};
|
|
667
|
+
child.on("exit", onExit);
|
|
668
|
+
return sess;
|
|
258
669
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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"})`;
|
|
267
705
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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);
|
|
718
|
+
}
|
|
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();
|
|
277
735
|
});
|
|
278
|
-
|
|
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.
|
|
279
803
|
try {
|
|
280
|
-
|
|
804
|
+
await Promise.resolve(context.route("**/*", routeHandler));
|
|
281
805
|
}
|
|
282
|
-
catch {
|
|
283
|
-
|
|
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)}`);
|
|
284
813
|
}
|
|
285
|
-
|
|
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;
|
|
286
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();
|
|
1017
|
+
}
|
|
1018
|
+
const ALL_ACTIONS = new Set([...BROWSER_PLAYWRIGHT_ACTIONS, ...BROWSER_COMPAT_ACTIONS, "navigate_page"]);
|
|
287
1019
|
const execute = async (rawArgs, ctx) => {
|
|
288
1020
|
try {
|
|
289
1021
|
const args = rawArgs ?? {};
|
|
@@ -291,8 +1023,10 @@ export function buildTmBrowserTool(deps) {
|
|
|
291
1023
|
const url = String(args.url ?? "").trim();
|
|
292
1024
|
// allowlist FIRST — an out-of-allowlist URL only proceeds after the
|
|
293
1025
|
// OFFICIAL dialog approves it (then the host passes the network layer
|
|
294
|
-
// too); hard red lines (scheme / env-file) reject with no dialog
|
|
295
|
-
|
|
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) {
|
|
296
1030
|
const verdict = checkWebUrl(url, allowlist);
|
|
297
1031
|
if (!verdict.ok) {
|
|
298
1032
|
if (!verdict.askable || !verdict.url) {
|
|
@@ -315,80 +1049,88 @@ export function buildTmBrowserTool(deps) {
|
|
|
315
1049
|
return toToolResult(tmError(tool, "args", "缺少 url 参数"));
|
|
316
1050
|
if (session) {
|
|
317
1051
|
// already open: navigate instead of spawning a second browser
|
|
318
|
-
return toToolResult(await
|
|
1052
|
+
return toToolResult(await session.act("navigate", { url }, "browser"));
|
|
319
1053
|
}
|
|
320
1054
|
const headless = args.headless != null ? Boolean(args.headless) : resolveHeadless(env);
|
|
321
1055
|
const s = await ensureSession(headless);
|
|
322
|
-
const out = await
|
|
1056
|
+
const out = await s.act("navigate", { url }, "browser");
|
|
323
1057
|
traj({ step_id: "browser", event: "open", headless });
|
|
324
|
-
|
|
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}`);
|
|
325
1064
|
}
|
|
326
|
-
if (action === "navigate") {
|
|
1065
|
+
if (action === "navigate" || action === "navigate_page") {
|
|
327
1066
|
if (!url)
|
|
328
1067
|
return toToolResult(tmError(tool, "args", "缺少 url 参数"));
|
|
329
1068
|
if (!session)
|
|
330
|
-
return toToolResult(tmError(tool, "args",
|
|
331
|
-
return toToolResult(await
|
|
332
|
-
}
|
|
333
|
-
if (action === "read") {
|
|
334
|
-
if (!session)
|
|
335
|
-
return toToolResult(tmError(tool, "args", "没有打开的浏览器会话——先用 action:\"open\""));
|
|
336
|
-
const stepId = pipelines.nextStepId();
|
|
337
|
-
traj({ step_id: stepId, event: "call", url: session.currentUrl.slice(0, 200) });
|
|
338
|
-
const ev = await session.cdp.call("Runtime.evaluate", { expression: "document.body ? document.body.innerText : ''", returnByValue: true }, session.sessionId);
|
|
339
|
-
const text = String(ev.result?.value ?? "");
|
|
340
|
-
traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
|
|
341
|
-
return toToolResult(`页面文本(${session.currentUrl}):\n${text.slice(0, 20000)}${text.length > 20000 ? "\n…(截断)" : ""}`);
|
|
342
|
-
}
|
|
343
|
-
if (action === "screenshot") {
|
|
344
|
-
if (!session)
|
|
345
|
-
return toToolResult(tmError(tool, "args", "没有打开的浏览器会话——先用 action:\"open\""));
|
|
346
|
-
const stepId = pipelines.nextStepId();
|
|
347
|
-
traj({ step_id: stepId, event: "call", kind: "screenshot", url: session.currentUrl.slice(0, 200) });
|
|
348
|
-
const shot = await session.cdp.call("Page.captureScreenshot", { format: "png" }, session.sessionId);
|
|
349
|
-
const data = String(shot.data ?? "");
|
|
350
|
-
const png = Buffer.from(data, "base64");
|
|
351
|
-
const dir = path.join(pipelines.store.stepsRoot(), stepId);
|
|
352
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
353
|
-
const file = path.join(dir, "screenshot.png");
|
|
354
|
-
fs.writeFileSync(file, png);
|
|
355
|
-
traj({ step_id: stepId, event: "result", bytes: png.length });
|
|
356
|
-
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"));
|
|
357
1071
|
}
|
|
358
1072
|
if (action === "close") {
|
|
359
1073
|
traj({ step_id: "browser", event: "close" });
|
|
360
|
-
return toToolResult(await
|
|
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=…] 寻址)`));
|
|
361
1078
|
}
|
|
362
|
-
|
|
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));
|
|
363
1093
|
}
|
|
364
1094
|
catch (err) {
|
|
365
1095
|
const e = err;
|
|
366
1096
|
return toToolResult(tmError(tool, "execute", String(e?.message ?? err ?? "browser 操作失败")));
|
|
367
1097
|
}
|
|
368
1098
|
};
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
}
|
|
376
|
-
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:
|
|
377
|
-
- open: { url } — launch (or reuse) the session and navigate. 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).
|
|
378
|
-
- 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.
|
|
379
|
-
- 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.
|
|
380
|
-
- 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).`;
|
|
381
1105
|
return {
|
|
382
1106
|
description: DESCRIPTION,
|
|
383
1107
|
args: deps.args ?? {
|
|
384
|
-
action: { descriptor: "action: open
|
|
385
|
-
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)" },
|
|
386
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" },
|
|
387
1125
|
},
|
|
388
1126
|
execute,
|
|
389
1127
|
dispose: () => {
|
|
390
|
-
void
|
|
1128
|
+
void closeActive().catch(() => { });
|
|
391
1129
|
},
|
|
1130
|
+
engineInfo: () => ({
|
|
1131
|
+
kind: lastSel?.kind ?? null,
|
|
1132
|
+
reason: lastSel?.reason ?? null,
|
|
1133
|
+
}),
|
|
392
1134
|
};
|
|
393
1135
|
}
|
|
394
1136
|
//# sourceMappingURL=browser.js.map
|