@te-river/opencode-team-mode 1.5.12 → 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 +27 -6
- package/README.zh-CN.md +25 -4
- package/dist/agents.d.ts +5 -1
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +20 -0
- package/dist/agents.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 +71 -8
- 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 +51 -2
- package/dist/prompts/shared.js.map +1 -1
- package/dist/prompts/specialists.d.ts +1 -1
- package/dist/prompts/specialists.d.ts.map +1 -1
- package/dist/prompts/specialists.js +38 -17
- package/dist/prompts/specialists.js.map +1 -1
- package/dist/tm/args-schema.d.ts +7 -1
- package/dist/tm/args-schema.d.ts.map +1 -1
- package/dist/tm/args-schema.js +9 -3
- 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 +66 -5
- package/dist/tm/browser.d.ts.map +1 -1
- package/dist/tm/browser.js +476 -89
- package/dist/tm/browser.js.map +1 -1
- package/dist/tm/config.d.ts +68 -0
- package/dist/tm/config.d.ts.map +1 -1
- package/dist/tm/config.js +62 -0
- 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 +27 -4
- package/dist/tm/index.d.ts.map +1 -1
- package/dist/tm/index.js +38 -3
- package/dist/tm/index.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 +51 -7
- package/dist/tm/search.d.ts.map +1 -1
- package/dist/tm/search.js +151 -37
- package/dist/tm/search.js.map +1 -1
- package/dist/tm/webfetch.d.ts +4 -0
- package/dist/tm/webfetch.d.ts.map +1 -1
- package/dist/tm/webfetch.js +33 -5
- 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 +2 -2
package/dist/tm/browser.js
CHANGED
|
@@ -30,16 +30,21 @@
|
|
|
30
30
|
* Hardening (unchanged from the CDP era, ported to both engines):
|
|
31
31
|
* - isolated temp profile (never the user's real browser profile);
|
|
32
32
|
* persistent login is ONLY via an explicit TM_BROWSER_USER_DATA_DIR;
|
|
33
|
-
* - domain allowlist enforced at the NETWORK layer
|
|
34
|
-
* context.route
|
|
35
|
-
*
|
|
33
|
+
* - domain allowlist enforced at the NETWORK layer on the NAVIGATION
|
|
34
|
+
* (playwright context.route / CDP Fetch interception), re-checked on
|
|
35
|
+
* every redirect hop; SUBRESOURCES then follow TM_BROWSER_SUBRESOURCE
|
|
36
|
+
* (default same-site: passive types load, an executable resource loads
|
|
37
|
+
* when it belongs to a site this session opened). Gating every request
|
|
38
|
+
* by the content allowlist is what made pages render picture-less — the
|
|
39
|
+
* 21 seeded hosts contain no CDN, so img/css/js died silently;
|
|
36
40
|
* - out-of-allowlist open/navigate routes through the OFFICIAL dialog
|
|
37
41
|
* BEFORE any spawn; approved hosts also pass the network layer;
|
|
38
42
|
* - hardened context: real-Chrome UA + zh-CN Accept-Language,
|
|
39
43
|
* no-first-run / no-extensions / mute launch flags, 3 s default action
|
|
40
44
|
* timeout (the prompt's "3000 ms budget" is the tool's default, not an
|
|
41
45
|
* aspiration), no networkidle waits anywhere;
|
|
42
|
-
* - dispose
|
|
46
|
+
* - dispose AWAITS the close, and an idle session reaps itself
|
|
47
|
+
* (TM_BROWSER_IDLE_MS) — an orphaned visible window is a user-facing bug.
|
|
43
48
|
*
|
|
44
49
|
* Environment adaptivity (different OpenCode hosts):
|
|
45
50
|
* - headful by default (Plan C); display-less Linux (no DISPLAY/
|
|
@@ -67,23 +72,42 @@ import { isEnvFilePath } from "../envprotect.js";
|
|
|
67
72
|
import { tmError, toToolResult } from "./result.js";
|
|
68
73
|
import { estimateTokens } from "./config.js";
|
|
69
74
|
import { rmForceSafe } from "../fs-safe.js";
|
|
70
|
-
/** Per-OS browser candidates, in preference order (first hit wins).
|
|
75
|
+
/** Per-OS browser candidates, in preference order (first hit wins). ONLY a
|
|
76
|
+
* fallback: on Windows the registry probe below is what actually honours the
|
|
77
|
+
* user's chosen channel. Edge/Chrome ship each channel in its OWN install
|
|
78
|
+
* directory (`Microsoft\EdgeBeta`, `EdgeDev`, `EdgeCanary`), so the stable
|
|
79
|
+
* path alone let a Beta-default host fall back to stable Edge (observed on
|
|
80
|
+
* Windows 11, 2026-09-18). */
|
|
71
81
|
const BROWSER_CANDIDATES = {
|
|
72
82
|
win32: [
|
|
73
83
|
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
74
84
|
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
85
|
+
"C:\\Program Files (x86)\\Microsoft\\Edge Beta\\Application\\msedge.exe",
|
|
86
|
+
"C:\\Program Files\\Microsoft\\Edge Beta\\Application\\msedge.exe",
|
|
87
|
+
"C:\\Program Files (x86)\\Microsoft\\Edge Dev\\Application\\msedge.exe",
|
|
88
|
+
"C:\\Program Files\\Microsoft\\Edge Dev\\Application\\msedge.exe",
|
|
89
|
+
"C:\\Program Files (x86)\\Microsoft\\Edge Canary\\Application\\msedge.exe",
|
|
90
|
+
"C:\\Program Files\\Microsoft\\Edge Canary\\Application\\msedge.exe",
|
|
75
91
|
"%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe",
|
|
76
92
|
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
77
93
|
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
94
|
+
"%LOCALAPPDATA%\\Google\\Chrome Beta\\Application\\chrome.exe",
|
|
95
|
+
"%LOCALAPPDATA%\\Google\\Chrome Dev\\Application\\chrome.exe",
|
|
96
|
+
"%LOCALAPPDATA%\\Google\\Chrome SxS\\Application\\chrome.exe",
|
|
78
97
|
],
|
|
79
98
|
darwin: [
|
|
80
99
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
81
100
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
101
|
+
"/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
|
|
102
|
+
"/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
|
|
103
|
+
"/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary",
|
|
82
104
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
83
105
|
],
|
|
84
106
|
linux: [
|
|
85
107
|
"/usr/bin/google-chrome",
|
|
86
108
|
"/usr/bin/google-chrome-stable",
|
|
109
|
+
"/usr/bin/google-chrome-beta",
|
|
110
|
+
"/usr/bin/google-chrome-unstable",
|
|
87
111
|
"/usr/bin/chromium-browser",
|
|
88
112
|
"/usr/bin/chromium",
|
|
89
113
|
"/snap/bin/chromium",
|
|
@@ -113,24 +137,84 @@ export function parseDesktopExec(text) {
|
|
|
113
137
|
const m = /^Exec\s*=\s*(?:"([^"]+)"|(\S+))/m.exec(String(text ?? ""));
|
|
114
138
|
return m ? (m[1] ?? m[2]) : null;
|
|
115
139
|
}
|
|
140
|
+
/** Registry roots that can carry a ProgId's `shell\open\command`. Edge is
|
|
141
|
+
* installed machine-wide (HKLM\SOFTWARE\Classes) while a per-user Chrome
|
|
142
|
+
* install registers HKCU\Software\Classes — probing HKCU only is what made
|
|
143
|
+
* a Beta-default host come back empty and fall through to the stable
|
|
144
|
+
* candidate path. */
|
|
145
|
+
const REG_CLASSES_ROOTS = ["HKCU\\Software\\Classes", "HKLM\\SOFTWARE\\Classes"];
|
|
146
|
+
/** Both URL schemes the shell association is recorded under. A user can
|
|
147
|
+
* set the two differently, so `http` alone is not "the default browser". */
|
|
148
|
+
const REG_URL_SCHEMES = ["http", "https"];
|
|
149
|
+
/** Channel install dirs keyed off the ProgId name (`MSEdgeBetaHTM`,
|
|
150
|
+
* `ChromeHTML`, …) — the last-resort probe when the launch command itself is
|
|
151
|
+
* unreadable but the ProgId still names the channel. NOTE the real Windows
|
|
152
|
+
* Edge dirs carry a SPACE ("Microsoft\Edge Beta"), which is what an
|
|
153
|
+
* `EdgeBeta` guess silently misses; and Chrome installs per-user under
|
|
154
|
+
* %LOCALAPPDATA% with a different exe name. */
|
|
155
|
+
const CHANNEL_INSTALL_BY_PROGID = [
|
|
156
|
+
[/edgebeta/i, "C:\\Program Files (x86)\\Microsoft\\Edge Beta\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge Beta\\Application\\msedge.exe"],
|
|
157
|
+
[/edgedev/i, "C:\\Program Files (x86)\\Microsoft\\Edge Dev\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge Dev\\Application\\msedge.exe"],
|
|
158
|
+
[/edgecanary|edgeappcanary/i, "C:\\Program Files (x86)\\Microsoft\\Edge Canary\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge Canary\\Application\\msedge.exe"],
|
|
159
|
+
[/msedge|edgehtm/i, "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"],
|
|
160
|
+
[/chromebeta/i, "%LOCALAPPDATA%\\Google\\Chrome Beta\\Application\\chrome.exe", "C:\\Program Files\\Google\\Chrome Beta\\Application\\chrome.exe"],
|
|
161
|
+
[/chromedev|chromejsx/i, "%LOCALAPPDATA%\\Google\\Chrome Dev\\Application\\chrome.exe", "C:\\Program Files\\Google\\Chrome Dev\\Application\\chrome.exe"],
|
|
162
|
+
[/chromehtml|chromechtml/i, "%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"],
|
|
163
|
+
];
|
|
164
|
+
/** Resolve a ProgId to its executable via every classes root. `reg query`
|
|
165
|
+
* exits non-zero (throws) for a missing key, so each probe is wrapped. */
|
|
166
|
+
function progIdToExecutable(progId, run) {
|
|
167
|
+
for (const root of REG_CLASSES_ROOTS) {
|
|
168
|
+
try {
|
|
169
|
+
const exe = parseRegCommand(run("reg", ["query", `${root}\\${progId}\\shell\\open\\command`, "/ve"]));
|
|
170
|
+
if (exe)
|
|
171
|
+
return exe;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* next root */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
116
179
|
/** The user's DEFAULT browser, when it is Chromium-family and present.
|
|
117
180
|
* null → the caller falls back to the per-OS probe list (detection is
|
|
118
181
|
* best-effort and must never throw into tool discovery). */
|
|
119
182
|
export function defaultBrowserExecutable(env = process.env, run = defaultRunner) {
|
|
120
183
|
try {
|
|
121
184
|
if (process.platform === "win32") {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
185
|
+
const accept = (exe) => exe && isChromiumFamily(exe) && fs.existsSync(exe) ? exe : null;
|
|
186
|
+
for (const scheme of REG_URL_SCHEMES) {
|
|
187
|
+
let progId = null;
|
|
188
|
+
try {
|
|
189
|
+
progId = parseProgId(run("reg", [
|
|
190
|
+
"query",
|
|
191
|
+
`HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\${scheme}\\UserChoice`,
|
|
192
|
+
"/v",
|
|
193
|
+
"ProgId",
|
|
194
|
+
]));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
progId = null;
|
|
198
|
+
}
|
|
199
|
+
if (!progId)
|
|
200
|
+
continue;
|
|
201
|
+
const direct = accept(progIdToExecutable(progId, run));
|
|
202
|
+
if (direct)
|
|
203
|
+
return direct;
|
|
204
|
+
// launch command unreadable (or points at a Firefox shim) — the
|
|
205
|
+
// ProgId itself still names the channel, so probe that channel's
|
|
206
|
+
// standard install dir before giving up on the user's default.
|
|
207
|
+
for (const [re, ...cands] of CHANNEL_INSTALL_BY_PROGID) {
|
|
208
|
+
if (!re.test(progId))
|
|
209
|
+
continue;
|
|
210
|
+
for (const cand of cands) {
|
|
211
|
+
const hit = accept(cand.replace(/%LOCALAPPDATA%/gi, String(env.LOCALAPPDATA ?? "").trim()));
|
|
212
|
+
if (hit)
|
|
213
|
+
return hit;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
134
218
|
}
|
|
135
219
|
if (process.platform === "linux") {
|
|
136
220
|
const desk = run("xdg-settings", ["get", "default-web-browser"]).trim();
|
|
@@ -157,6 +241,23 @@ export function defaultBrowserExecutable(env = process.env, run = defaultRunner)
|
|
|
157
241
|
}
|
|
158
242
|
return null;
|
|
159
243
|
}
|
|
244
|
+
/** Playwright's `channel` launch IGNORES the executable we discovered —
|
|
245
|
+
* `channel:"msedge"` resolves whatever playwright thinks the stable Edge is,
|
|
246
|
+
* so a correct Edge-Beta discovery still opened stable (the decisive half of
|
|
247
|
+
* the 2026-09-18 report). The channel spelling is therefore only correct
|
|
248
|
+
* for the exact stable-channel install dir; everything else (Beta/Dev/
|
|
249
|
+
* Canary, portable, an explicit TM_BROWSER_PATH) rides on executablePath. */
|
|
250
|
+
export function playwrightLaunchTarget(executable, opts = {}) {
|
|
251
|
+
const exe = String(executable ?? "");
|
|
252
|
+
const p = exe.replace(/\//g, "\\").toLowerCase();
|
|
253
|
+
if (!opts.explicitOverride) {
|
|
254
|
+
if (/\\microsoft\\edge\\application\\msedge\.exe$/.test(p))
|
|
255
|
+
return { executablePath: exe, channel: "msedge" };
|
|
256
|
+
if (/\\google\\chrome\\application\\chrome\.exe$/.test(p))
|
|
257
|
+
return { executablePath: exe, channel: "chrome" };
|
|
258
|
+
}
|
|
259
|
+
return { executablePath: exe };
|
|
260
|
+
}
|
|
160
261
|
/** Resolve the browser executable: TM_BROWSER_PATH override → the user's
|
|
161
262
|
* DEFAULT browser (Chromium-family only) → per-OS candidate paths.
|
|
162
263
|
* %LOCALAPPDATA% style vars are expanded on win32. */
|
|
@@ -200,6 +301,83 @@ export function resolveHeadless(env = process.env) {
|
|
|
200
301
|
return !env.DISPLAY && !env.WAYLAND_DISPLAY;
|
|
201
302
|
return false;
|
|
202
303
|
}
|
|
304
|
+
// ---------- subresource gate (the "pages render without images" fix) --------
|
|
305
|
+
/** A resource the page cannot execute code with — it can only paint. */
|
|
306
|
+
const PASSIVE_RESOURCE_TYPES = new Set(["image", "media", "font", "stylesheet"]);
|
|
307
|
+
/** Public suffixes that make the last TWO labels insufficient for a site
|
|
308
|
+
* comparison. Deliberately a small spelled-out list (no PSL dependency):
|
|
309
|
+
* an unlisted multi-label suffix yields a LONGER site key, which auto-passes
|
|
310
|
+
* FEWER hosts — the conservative direction for a network gate. */
|
|
311
|
+
const TWO_LABEL_SUFFIXES = new Set([
|
|
312
|
+
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn", "co.cn", "com.hk", "org.hk", "co.jp", "ne.jp",
|
|
313
|
+
"co.kr", "co.uk", "org.uk", "ac.uk", "com.au", "net.au", "org.au", "co.in", "com.br", "com.mx", "com.tr",
|
|
314
|
+
"com.ua", "com.sg", "com.my", "com.tw", "co.za", "com.pl", "com.ru", "com.vn", "com.id", "com.ar", "com.co",
|
|
315
|
+
]);
|
|
316
|
+
/** Approximate registrable site (eTLD+1) of a hostname. */
|
|
317
|
+
export function siteOf(host) {
|
|
318
|
+
const h = String(host ?? "").toLowerCase().replace(/\.$/, "");
|
|
319
|
+
if (!h || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(h))
|
|
320
|
+
return h;
|
|
321
|
+
const labels = h.split(".").filter(Boolean);
|
|
322
|
+
if (labels.length <= 2)
|
|
323
|
+
return labels.join(".");
|
|
324
|
+
const lastTwo = labels.slice(-2).join(".");
|
|
325
|
+
if (TWO_LABEL_SUFFIXES.has(lastTwo))
|
|
326
|
+
return labels.slice(-3).join(".");
|
|
327
|
+
return lastTwo;
|
|
328
|
+
}
|
|
329
|
+
/** playwright and CDP spell resource types differently (`Image` vs `image`,
|
|
330
|
+
* `Stylesheet` vs `stylesheet`) — one normalizer keeps both engines on the
|
|
331
|
+
* same verdict table. */
|
|
332
|
+
export function normalizeResourceType(raw) {
|
|
333
|
+
const t = String(raw ?? "").trim().toLowerCase();
|
|
334
|
+
return t === "" ? "other" : t;
|
|
335
|
+
}
|
|
336
|
+
export function isPassiveResource(raw) {
|
|
337
|
+
return PASSIVE_RESOURCE_TYPES.has(normalizeResourceType(raw));
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* One subresource verdict, shared VERBATIM by both engines so playwright and
|
|
341
|
+
* cdp-legacy can never drift. `allowedSites` holds the registrable sites of
|
|
342
|
+
* every host this session actually navigated to (allowlist hit or dialog
|
|
343
|
+
* approval), which is what makes a page's own JS/CSS work without opening
|
|
344
|
+
* the whole internet: the governance boundary moves from "21 content domains"
|
|
345
|
+
* to "the site you approved", and an off-site SCRIPT still needs a dialog.
|
|
346
|
+
*/
|
|
347
|
+
export function subresourcePass(opts) {
|
|
348
|
+
if (opts.allowlistHit)
|
|
349
|
+
return { pass: true, via: "allowlist" };
|
|
350
|
+
if (opts.approvedHost)
|
|
351
|
+
return { pass: true, via: "approved" };
|
|
352
|
+
if (opts.policy === "off")
|
|
353
|
+
return { pass: false, via: "blocked" };
|
|
354
|
+
let host;
|
|
355
|
+
try {
|
|
356
|
+
host = new URL(opts.url).hostname;
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return { pass: false, via: "blocked" };
|
|
360
|
+
}
|
|
361
|
+
const type = normalizeResourceType(opts.resourceType);
|
|
362
|
+
if (PASSIVE_RESOURCE_TYPES.has(type))
|
|
363
|
+
return { pass: true, via: `passive:${type}` };
|
|
364
|
+
if (opts.policy === "passive")
|
|
365
|
+
return { pass: false, via: `blocked:${type}` };
|
|
366
|
+
return opts.allowedSites.has(siteOf(host)) ? { pass: true, via: `same-site:${type}` } : { pass: false, via: `blocked:${type}` };
|
|
367
|
+
}
|
|
368
|
+
/** A navigation (or a host the dialog approved) widens the subresource
|
|
369
|
+
* footprint to its own site. Called for EVERY top-level/document request
|
|
370
|
+
* that passed the allowlist side. */
|
|
371
|
+
export function rememberSite(allowedSites, url) {
|
|
372
|
+
try {
|
|
373
|
+
const host = new URL(url).hostname;
|
|
374
|
+
if (host)
|
|
375
|
+
allowedSites.add(siteOf(host));
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
/* unparseable — nothing to remember */
|
|
379
|
+
}
|
|
380
|
+
}
|
|
203
381
|
class PipeCdp {
|
|
204
382
|
child;
|
|
205
383
|
nextId = 1;
|
|
@@ -425,6 +603,7 @@ const NAVIGATE_EVENT_TIMEOUT_MS = 15_000;
|
|
|
425
603
|
export function buildTmBrowserTool(deps) {
|
|
426
604
|
const { pipelines } = deps;
|
|
427
605
|
const env = deps.env ?? process.env;
|
|
606
|
+
const notify = typeof deps.notify === "function" ? deps.notify : undefined;
|
|
428
607
|
const tool = "tm_browser";
|
|
429
608
|
const traj = (e) => pipelines.store.appendTrajectory({ tool, ...e });
|
|
430
609
|
let session = null;
|
|
@@ -437,6 +616,10 @@ export function buildTmBrowserTool(deps) {
|
|
|
437
616
|
// network layer (both engines) consults this in addition to the static allowlist
|
|
438
617
|
const approvedHosts = new Set();
|
|
439
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);
|
|
440
623
|
// ---------- engine selection (cached per plugin instance) ----------
|
|
441
624
|
let selPromise = null;
|
|
442
625
|
let lastSel = null;
|
|
@@ -479,8 +662,71 @@ export function buildTmBrowserTool(deps) {
|
|
|
479
662
|
traj({ step_id: stepId, event: "result", bytes: png.length });
|
|
480
663
|
return { file, png };
|
|
481
664
|
}
|
|
482
|
-
|
|
483
|
-
|
|
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};同域资源已自动放行,跨域脚本仍需白名单或弹窗批准。`);
|
|
484
730
|
}
|
|
485
731
|
function pageTextOutput(url, text) {
|
|
486
732
|
return `页面文本(${url}):\n${text.slice(0, 20000)}${text.length > 20000 ? "\n…(截断)" : ""}`;
|
|
@@ -517,8 +763,52 @@ export function buildTmBrowserTool(deps) {
|
|
|
517
763
|
return null;
|
|
518
764
|
}
|
|
519
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
|
+
}
|
|
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
|
+
}
|
|
520
810
|
async function openLegacySession(headless) {
|
|
521
|
-
const executable =
|
|
811
|
+
const executable = discover(env);
|
|
522
812
|
if (!executable)
|
|
523
813
|
throw discoveryError();
|
|
524
814
|
const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-browser-"));
|
|
@@ -557,56 +847,58 @@ export function buildTmBrowserTool(deps) {
|
|
|
557
847
|
throw new Error("浏览器启动后未找到 page target");
|
|
558
848
|
const { sessionId } = (await cdp.call("Target.attachToTarget", { targetId: page.targetId, flatten: true }));
|
|
559
849
|
await cdp.call("Page.enable", {}, sessionId);
|
|
560
|
-
// 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() };
|
|
561
854
|
await cdp.call("Fetch.enable", { patterns: [{ urlPattern: "*" }] }, sessionId);
|
|
562
855
|
cdp.onEvent((m) => {
|
|
563
856
|
if (m.method !== "Fetch.requestPaused" || m.sessionId !== sessionId)
|
|
564
857
|
return;
|
|
565
858
|
const requestId = String(m.params.requestId ?? "");
|
|
566
|
-
const
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
if (!pass) {
|
|
570
|
-
// an approved-via-dialog host passes the network layer too
|
|
571
|
-
try {
|
|
572
|
-
pass = approvedHosts.has(new URL(url).hostname);
|
|
573
|
-
}
|
|
574
|
-
catch {
|
|
575
|
-
pass = false;
|
|
576
|
-
}
|
|
577
|
-
}
|
|
859
|
+
const p = m.params.request ?? {};
|
|
860
|
+
const url = String(p.url ?? "");
|
|
861
|
+
const { pass } = gateDecide({ allowedSites, blocked }, url, p.resourceType);
|
|
578
862
|
if (pass) {
|
|
579
863
|
void cdp.call("Fetch.continueRequest", { requestId }, sessionId, 5000).catch(() => { });
|
|
580
864
|
}
|
|
581
865
|
else {
|
|
582
|
-
traj({ step_id: "browser", event: "blocked", url: url.slice(0, 200) });
|
|
583
866
|
void cdp.call("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }, sessionId, 5000).catch(() => { });
|
|
584
867
|
}
|
|
585
868
|
});
|
|
586
869
|
const sess = {
|
|
587
870
|
kind: "cdp-legacy",
|
|
588
871
|
currentUrl: "about:blank",
|
|
872
|
+
headless,
|
|
873
|
+
label: `cdp-legacy · ${path.basename(executable)} · pid ${child.pid ?? "?"}`,
|
|
874
|
+
attachments: [],
|
|
875
|
+
blocked,
|
|
876
|
+
allowedSites,
|
|
589
877
|
async act(action, args, stepId) {
|
|
590
878
|
if (action === "navigate" || action === "navigate_page") {
|
|
591
879
|
const url = String(args.url ?? "").trim();
|
|
880
|
+
rememberSite(allowedSites, url);
|
|
592
881
|
await cdp.call("Page.navigate", { url }, sessionId);
|
|
593
882
|
await cdp.waitEvent("Page.loadEventFired", sessionId, NAVIGATE_EVENT_TIMEOUT_MS).catch(() => { });
|
|
594
883
|
sess.currentUrl = url;
|
|
595
|
-
return `已导航:${url}`;
|
|
884
|
+
return `已导航:${url}${blockedNote(sess)}`;
|
|
596
885
|
}
|
|
597
886
|
if (action === "read") {
|
|
598
887
|
traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
|
|
599
888
|
const ev = await cdp.call("Runtime.evaluate", { expression: "document.body ? document.body.innerText : ''", returnByValue: true }, sessionId);
|
|
600
889
|
const text = String(ev.result?.value ?? "");
|
|
601
890
|
traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
|
|
602
|
-
return pageTextOutput(sess.currentUrl, text)
|
|
891
|
+
return `${pageTextOutput(sess.currentUrl, text)}${blockedNote(sess)}`;
|
|
603
892
|
}
|
|
604
893
|
if (action === "screenshot" || action === "take_screenshot") {
|
|
605
894
|
traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
|
|
606
895
|
const shot = await cdp.call("Page.captureScreenshot", { format: "png" }, sessionId);
|
|
607
896
|
const png = Buffer.from(String(shot.data ?? ""), "base64");
|
|
608
897
|
const { file } = saveShotPng(new Uint8Array(png), stepId);
|
|
609
|
-
return shotOutput(file, png
|
|
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
|
+
});
|
|
610
902
|
}
|
|
611
903
|
if (action === "list_pages") {
|
|
612
904
|
const t = await cdp.call("Target.getTargets");
|
|
@@ -643,13 +935,20 @@ export function buildTmBrowserTool(deps) {
|
|
|
643
935
|
res();
|
|
644
936
|
});
|
|
645
937
|
});
|
|
938
|
+
const dead = child.exitCode !== null;
|
|
646
939
|
try {
|
|
647
940
|
rmForceSafe(profileDir, { recursive: true });
|
|
648
941
|
}
|
|
649
942
|
catch {
|
|
650
943
|
/* a locked leftover temp dir is reclaimed by the OS — never fail close */
|
|
651
944
|
}
|
|
652
|
-
return
|
|
945
|
+
return dead
|
|
946
|
+
? { closed: true, text: "浏览器进程已退出(cdp-legacy),临时配置目录已清理。" }
|
|
947
|
+
: {
|
|
948
|
+
closed: false,
|
|
949
|
+
text: `警告:cdp-legacy 子进程 pid ${child.pid ?? "?"} 未在 3s 内退出,窗口可能仍在前台——` +
|
|
950
|
+
`请手动关闭该浏览器窗口(临时目录 ${profileDir})。`,
|
|
951
|
+
};
|
|
653
952
|
},
|
|
654
953
|
};
|
|
655
954
|
const onExit = () => {
|
|
@@ -669,7 +968,7 @@ export function buildTmBrowserTool(deps) {
|
|
|
669
968
|
}
|
|
670
969
|
// ================= playwright engine (primary) ==============================
|
|
671
970
|
async function openPlaywrightSession(pw, headless) {
|
|
672
|
-
const executable =
|
|
971
|
+
const executable = discover(env);
|
|
673
972
|
if (!executable)
|
|
674
973
|
throw discoveryError();
|
|
675
974
|
const launchArgs = [
|
|
@@ -687,32 +986,35 @@ export function buildTmBrowserTool(deps) {
|
|
|
687
986
|
viewport: headless ? undefined : null,
|
|
688
987
|
};
|
|
689
988
|
const persistentDir = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim();
|
|
690
|
-
// R2
|
|
691
|
-
//
|
|
692
|
-
// executablePath
|
|
693
|
-
|
|
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
|
+
});
|
|
694
996
|
let browser = null;
|
|
695
997
|
let context;
|
|
696
998
|
let via = "executablePath";
|
|
697
999
|
if (persistentDir) {
|
|
698
1000
|
context = await pw.chromium.launchPersistentContext(persistentDir, {
|
|
699
1001
|
...contextOpts,
|
|
700
|
-
|
|
1002
|
+
executablePath: target.executablePath,
|
|
701
1003
|
headless,
|
|
702
1004
|
args: launchArgs,
|
|
703
1005
|
});
|
|
704
|
-
via = `persistent(
|
|
1006
|
+
via = `persistent(executablePath)`;
|
|
705
1007
|
}
|
|
706
1008
|
else {
|
|
707
1009
|
try {
|
|
708
|
-
browser = await pw.chromium.launch({
|
|
709
|
-
via =
|
|
1010
|
+
browser = await pw.chromium.launch({ executablePath: target.executablePath, headless, args: launchArgs });
|
|
1011
|
+
via = "executablePath";
|
|
710
1012
|
}
|
|
711
1013
|
catch (e1) {
|
|
712
|
-
if (!channel)
|
|
1014
|
+
if (!target.channel)
|
|
713
1015
|
throw e1;
|
|
714
|
-
browser = await pw.chromium.launch({
|
|
715
|
-
via =
|
|
1016
|
+
browser = await pw.chromium.launch({ channel: target.channel, headless, args: launchArgs });
|
|
1017
|
+
via = `channel(${target.channel})`;
|
|
716
1018
|
}
|
|
717
1019
|
context = await browser.newContext(contextOpts);
|
|
718
1020
|
}
|
|
@@ -722,6 +1024,8 @@ export function buildTmBrowserTool(deps) {
|
|
|
722
1024
|
dialogs: [],
|
|
723
1025
|
consoleBuf: [],
|
|
724
1026
|
netBuf: [],
|
|
1027
|
+
allowedSites: new Set(),
|
|
1028
|
+
blocked: { count: 0, hosts: new Set() },
|
|
725
1029
|
};
|
|
726
1030
|
const attached = new WeakSet();
|
|
727
1031
|
const attach = (p) => {
|
|
@@ -775,22 +1079,12 @@ export function buildTmBrowserTool(deps) {
|
|
|
775
1079
|
await route.continue().catch(() => { });
|
|
776
1080
|
return;
|
|
777
1081
|
}
|
|
778
|
-
const
|
|
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
|
-
}
|
|
1082
|
+
const { pass } = gateDecide(state, u, request.resourceType?.());
|
|
788
1083
|
if (pass) {
|
|
789
1084
|
await route.continue().catch(() => { });
|
|
790
1085
|
}
|
|
791
1086
|
else {
|
|
792
1087
|
// per-hop re-check lands here for every redirect hop too
|
|
793
|
-
traj({ step_id: "browser", event: "blocked", url: u.slice(0, 200) });
|
|
794
1088
|
state.netBuf.push({ method: request.method?.() ?? "?", url: u.slice(0, 300), blocked: true });
|
|
795
1089
|
await route.abort().catch(() => { });
|
|
796
1090
|
}
|
|
@@ -839,25 +1133,34 @@ export function buildTmBrowserTool(deps) {
|
|
|
839
1133
|
const sess = {
|
|
840
1134
|
kind: "playwright",
|
|
841
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,
|
|
842
1141
|
async act(action, args, stepId) {
|
|
843
1142
|
if (action === "navigate" || action === "navigate_page") {
|
|
844
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);
|
|
845
1147
|
// domcontentloaded ONLY — networkidle is banned (prompt discipline)
|
|
846
1148
|
await state.page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
|
847
1149
|
sess.currentUrl = state.page.url?.() || url;
|
|
848
|
-
|
|
1150
|
+
rememberSite(state.allowedSites, sess.currentUrl);
|
|
1151
|
+
return `已导航:${url}${blockedNote(sess)}`;
|
|
849
1152
|
}
|
|
850
1153
|
if (action === "read") {
|
|
851
1154
|
traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
|
|
852
1155
|
const text = String((await state.page.evaluate("document.body ? document.body.innerText : ''")) ?? "");
|
|
853
1156
|
traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
|
|
854
|
-
return pageTextOutput(sess.currentUrl, text)
|
|
1157
|
+
return `${pageTextOutput(sess.currentUrl, text)}${blockedNote(sess)}`;
|
|
855
1158
|
}
|
|
856
1159
|
if (action === "screenshot" || action === "take_screenshot") {
|
|
857
1160
|
traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
|
|
858
1161
|
const bytes = await state.page.screenshot({ type: "png", fullPage: Boolean(args.fullPage) });
|
|
859
1162
|
const { file, png } = saveShotPng(bytes, stepId);
|
|
860
|
-
return shotOutput(file, png.
|
|
1163
|
+
return shotOutput(sess, file, png, args, async () => Buffer.from(await state.page.screenshot({ type: "jpeg", quality: 70, fullPage: Boolean(args.fullPage) })));
|
|
861
1164
|
}
|
|
862
1165
|
if (action === "take_snapshot") {
|
|
863
1166
|
traj({ step_id: stepId, event: "call", kind: "snapshot", url: sess.currentUrl.slice(0, 200) });
|
|
@@ -866,13 +1169,13 @@ export function buildTmBrowserTool(deps) {
|
|
|
866
1169
|
yaml = await state.page.locator("body").ariaSnapshot();
|
|
867
1170
|
}
|
|
868
1171
|
catch (e) {
|
|
869
|
-
throw new Error(`ariaSnapshot
|
|
1172
|
+
throw new Error(`ariaSnapshot 不可用:${e.message} —— locator.ariaSnapshot 需要 playwright-core ≥1.49(本包 optionalDependencies 锁 1.63);未安装或过旧会走 cdp-legacy 降级,那边没有快照动作。`);
|
|
870
1173
|
}
|
|
871
1174
|
const annotated = snapIndex.annotate(yaml);
|
|
872
1175
|
traj({ step_id: stepId, event: "result", tokens: estimateTokens(annotated), nodes: snapIndex.size });
|
|
873
1176
|
const body = capTokens(annotated, `…(快照超过 browserSnapshotMaxTokens=${snapshotBudget},后续行已截断——用 read 拿原文或先滚动再快照)`);
|
|
874
1177
|
const hint = state.dialogs.length ? `\n注意:有 ${state.dialogs.length} 个未处理对话框——先 handle_dialog` : "";
|
|
875
|
-
return `ARIA 快照(${sess.currentUrl} · ${snapIndex.size} 个可寻址节点)——后续动作按行内 [uid=eN] 寻址:\n${body}${hint}`;
|
|
1178
|
+
return `ARIA 快照(${sess.currentUrl} · ${snapIndex.size} 个可寻址节点)——后续动作按行内 [uid=eN] 寻址:\n${body}${hint}${blockedNote(sess)}`;
|
|
876
1179
|
}
|
|
877
1180
|
if (action === "click") {
|
|
878
1181
|
const loc = targetOf("uid", "selector", args);
|
|
@@ -984,20 +1287,63 @@ export function buildTmBrowserTool(deps) {
|
|
|
984
1287
|
},
|
|
985
1288
|
async close() {
|
|
986
1289
|
state.dialogs.length = 0;
|
|
987
|
-
|
|
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"));
|
|
988
1295
|
if (browser)
|
|
989
|
-
await Promise.resolve(browser.close()).catch(()
|
|
990
|
-
|
|
991
|
-
|
|
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;
|
|
992
1305
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
+
};
|
|
996
1319
|
},
|
|
997
1320
|
};
|
|
998
1321
|
return sess;
|
|
999
1322
|
}
|
|
1000
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
|
+
};
|
|
1001
1347
|
async function ensureSession(headless) {
|
|
1002
1348
|
if (session)
|
|
1003
1349
|
return session;
|
|
@@ -1011,10 +1357,26 @@ export function buildTmBrowserTool(deps) {
|
|
|
1011
1357
|
async function closeActive() {
|
|
1012
1358
|
const s = session;
|
|
1013
1359
|
if (!s)
|
|
1014
|
-
return "没有打开的浏览器会话。";
|
|
1360
|
+
return { text: "没有打开的浏览器会话。", closed: true };
|
|
1015
1361
|
session = null;
|
|
1362
|
+
if (idleTimer) {
|
|
1363
|
+
clearTimeout(idleTimer);
|
|
1364
|
+
idleTimer = null;
|
|
1365
|
+
}
|
|
1016
1366
|
return s.close();
|
|
1017
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
|
+
}
|
|
1018
1380
|
const ALL_ACTIONS = new Set([...BROWSER_PLAYWRIGHT_ACTIONS, ...BROWSER_COMPAT_ACTIONS, "navigate_page"]);
|
|
1019
1381
|
const execute = async (rawArgs, ctx) => {
|
|
1020
1382
|
try {
|
|
@@ -1048,30 +1410,46 @@ export function buildTmBrowserTool(deps) {
|
|
|
1048
1410
|
if (!url)
|
|
1049
1411
|
return toToolResult(tmError(tool, "args", "缺少 url 参数"));
|
|
1050
1412
|
if (session) {
|
|
1051
|
-
// already open: navigate instead of spawning a second browser
|
|
1052
|
-
|
|
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);
|
|
1053
1420
|
}
|
|
1054
|
-
|
|
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);
|
|
1055
1426
|
const s = await ensureSession(headless);
|
|
1056
1427
|
const out = await s.act("navigate", { url }, "browser");
|
|
1057
|
-
traj({ step_id: "browser", event: "open", headless });
|
|
1058
|
-
const mode = headless ? "无头" : "有头窗口";
|
|
1428
|
+
traj({ step_id: "browser", event: "open", headless, label: s.label });
|
|
1429
|
+
const mode = s.headless ? "无头" : "有头窗口";
|
|
1059
1430
|
const profile = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim() ? "持久登录配置" : "隔离临时配置";
|
|
1060
1431
|
const note = lastSel && preference !== "cdp-legacy" && lastSel.kind === "cdp-legacy"
|
|
1061
1432
|
? `\n(引擎:cdp-legacy 降级——${lastSel.reason ?? ""};完整 16 动作需 npm install playwright-core + node≥20)`
|
|
1062
1433
|
: "";
|
|
1063
|
-
|
|
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);
|
|
1064
1439
|
}
|
|
1065
1440
|
if (action === "navigate" || action === "navigate_page") {
|
|
1066
1441
|
if (!url)
|
|
1067
1442
|
return toToolResult(tmError(tool, "args", "缺少 url 参数"));
|
|
1068
1443
|
if (!session)
|
|
1069
1444
|
return toToolResult(tmError(tool, "args", '没有打开的浏览器会话——先用 action:"open"'));
|
|
1070
|
-
|
|
1445
|
+
const out = await session.act(action, { url }, "browser");
|
|
1446
|
+
armIdle();
|
|
1447
|
+
return withAttachments(toToolResult(out), session);
|
|
1071
1448
|
}
|
|
1072
1449
|
if (action === "close") {
|
|
1073
1450
|
traj({ step_id: "browser", event: "close" });
|
|
1074
|
-
|
|
1451
|
+
const r = await closeActive();
|
|
1452
|
+
return toToolResult(r.text);
|
|
1075
1453
|
}
|
|
1076
1454
|
if (!ALL_ACTIONS.has(action)) {
|
|
1077
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=…] 寻址)`));
|
|
@@ -1089,7 +1467,9 @@ export function buildTmBrowserTool(deps) {
|
|
|
1089
1467
|
}
|
|
1090
1468
|
}
|
|
1091
1469
|
const stepId = pipelines.nextStepId();
|
|
1092
|
-
|
|
1470
|
+
const out = await session.act(action, args, stepId);
|
|
1471
|
+
armIdle();
|
|
1472
|
+
return withAttachments(toToolResult(out), session);
|
|
1093
1473
|
}
|
|
1094
1474
|
catch (err) {
|
|
1095
1475
|
const e = err;
|
|
@@ -1097,17 +1477,18 @@ export function buildTmBrowserTool(deps) {
|
|
|
1097
1477
|
}
|
|
1098
1478
|
};
|
|
1099
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.
|
|
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
|
|
1101
|
-
- Compat actions: open { url } — launch/reuse + navigate
|
|
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.
|
|
1102
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.
|
|
1103
|
-
- Network:
|
|
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.
|
|
1104
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).`;
|
|
1105
1486
|
return {
|
|
1106
1487
|
description: DESCRIPTION,
|
|
1107
1488
|
args: deps.args ?? {
|
|
1108
1489
|
action: { descriptor: "action: take_snapshot|click|fill|navigate_page|... (16 playwright verbs + open/navigate/read/screenshot/close)" },
|
|
1109
1490
|
url: { descriptor: "url: string (open/navigate/navigate_page, allowlisted https)" },
|
|
1110
|
-
|
|
1491
|
+
image: { descriptor: "image: true (take_screenshot only — attach the PNG pixels to this result; default is path-only)" },
|
|
1111
1492
|
uid: { descriptor: "uid: snapshot [uid=eN] token (click/fill/hover/drag/upload_file/wait_for)" },
|
|
1112
1493
|
selector: { descriptor: "selector: CSS/text locator escape hatch (only when a snapshot cannot express the node)" },
|
|
1113
1494
|
targetUid: { descriptor: "targetUid: drag destination uid" },
|
|
@@ -1124,8 +1505,14 @@ export function buildTmBrowserTool(deps) {
|
|
|
1124
1505
|
clear: { descriptor: "clear: drain the console buffer after list_console_messages" },
|
|
1125
1506
|
},
|
|
1126
1507
|
execute,
|
|
1127
|
-
dispose: () => {
|
|
1128
|
-
|
|
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(() => { });
|
|
1129
1516
|
},
|
|
1130
1517
|
engineInfo: () => ({
|
|
1131
1518
|
kind: lastSel?.kind ?? null,
|