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