@te-river/opencode-team-mode 1.7.2 → 1.7.3
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 +1009 -849
- package/README.zh-CN.md +906 -756
- package/dist/host/v2-browser-gate.d.ts +4 -1
- package/dist/host/v2-browser-gate.d.ts.map +1 -1
- package/dist/host/v2-browser-gate.js +1 -1
- package/dist/host/v2-browser-gate.js.map +1 -1
- package/dist/host/v2-capabilities.js +1 -1
- package/dist/host/v2-capabilities.js.map +1 -1
- package/dist/host/v2-guard.d.ts +3 -1
- package/dist/host/v2-guard.d.ts.map +1 -1
- package/dist/host/v2-guard.js +2 -2
- package/dist/host/v2-guard.js.map +1 -1
- package/dist/host/v2-offload.d.ts +12 -2
- package/dist/host/v2-offload.d.ts.map +1 -1
- package/dist/host/v2-offload.js +9 -9
- package/dist/host/v2-offload.js.map +1 -1
- package/dist/host/v2-probe-chain.d.ts +5 -1
- package/dist/host/v2-probe-chain.d.ts.map +1 -1
- package/dist/host/v2-probe-chain.js +4 -6
- package/dist/host/v2-probe-chain.js.map +1 -1
- package/dist/host/v2-probe.d.ts.map +1 -1
- package/dist/host/v2-probe.js +2 -1
- package/dist/host/v2-probe.js.map +1 -1
- package/dist/host/v2-prune.d.ts +5 -1
- package/dist/host/v2-prune.d.ts.map +1 -1
- package/dist/host/v2-prune.js +8 -14
- package/dist/host/v2-prune.js.map +1 -1
- package/dist/host/v2-retry.d.ts +9 -2
- package/dist/host/v2-retry.d.ts.map +1 -1
- package/dist/host/v2-retry.js +13 -15
- package/dist/host/v2-retry.js.map +1 -1
- package/dist/host/v2-session.d.ts +6 -2
- package/dist/host/v2-session.d.ts.map +1 -1
- package/dist/host/v2-session.js +7 -13
- package/dist/host/v2-session.js.map +1 -1
- package/dist/host/v2.d.ts +7 -2
- package/dist/host/v2.d.ts.map +1 -1
- package/dist/host/v2.js +38 -35
- package/dist/host/v2.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 +23 -7
- package/dist/prompts/lead.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 +1 -1
- package/dist/tm/args-schema.d.ts +1 -1
- package/dist/tm/args-schema.js +6 -6
- package/dist/tm/args-schema.js.map +1 -1
- package/dist/tm/board.js +2 -2
- package/dist/tm/board.js.map +1 -1
- package/dist/tm/config-files.d.ts +41 -17
- package/dist/tm/config-files.d.ts.map +1 -1
- package/dist/tm/config-files.js +68 -73
- package/dist/tm/config-files.js.map +1 -1
- package/dist/tm/config-layers.d.ts +65 -33
- package/dist/tm/config-layers.d.ts.map +1 -1
- package/dist/tm/config-layers.js +165 -81
- package/dist/tm/config-layers.js.map +1 -1
- package/dist/tm/config-template.d.ts +32 -0
- package/dist/tm/config-template.d.ts.map +1 -0
- package/dist/tm/config-template.js +74 -0
- package/dist/tm/config-template.js.map +1 -0
- package/dist/tm/config.d.ts +115 -140
- package/dist/tm/config.d.ts.map +1 -1
- package/dist/tm/config.js +100 -200
- package/dist/tm/config.js.map +1 -1
- package/dist/tm/index.d.ts +11 -3
- package/dist/tm/index.d.ts.map +1 -1
- package/dist/tm/index.js +46 -16
- package/dist/tm/index.js.map +1 -1
- package/dist/tm/ledger.d.ts +7 -3
- package/dist/tm/ledger.d.ts.map +1 -1
- package/dist/tm/ledger.js +5 -4
- package/dist/tm/ledger.js.map +1 -1
- package/dist/tm/memory.js +3 -3
- package/dist/tm/pipelines.js +1 -1
- package/dist/tm/search.js +6 -6
- package/dist/tm/search.js.map +1 -1
- package/dist/tm/stats.js +2 -2
- package/dist/tm/stats.js.map +1 -1
- package/dist/tm/tools.js +2 -2
- package/dist/tm/webfetch.d.ts +5 -2
- package/dist/tm/webfetch.d.ts.map +1 -1
- package/dist/tm/webfetch.js +24 -18
- package/dist/tm/webfetch.js.map +1 -1
- package/docs/installation-v2.md +59 -38
- package/docs/installation.md +4 -4
- package/package.json +1 -1
- package/dist/approval-gate.d.ts +0 -205
- package/dist/approval-gate.d.ts.map +0 -1
- package/dist/approval-gate.js +0 -627
- package/dist/approval-gate.js.map +0 -1
- package/dist/host/v1.d.ts +0 -14
- package/dist/host/v1.d.ts.map +0 -1
- package/dist/host/v1.js +0 -340
- package/dist/host/v1.js.map +0 -1
- package/dist/host/v2-client.d.ts +0 -53
- package/dist/host/v2-client.d.ts.map +0 -1
- package/dist/host/v2-client.js +0 -193
- package/dist/host/v2-client.js.map +0 -1
- package/dist/host/v2-transcript.d.ts +0 -73
- package/dist/host/v2-transcript.d.ts.map +0 -1
- package/dist/host/v2-transcript.js +0 -151
- package/dist/host/v2-transcript.js.map +0 -1
- package/dist/host-hooks.d.ts +0 -126
- package/dist/host-hooks.d.ts.map +0 -1
- package/dist/host-hooks.js +0 -284
- package/dist/host-hooks.js.map +0 -1
- package/dist/tm/bash-timeout.d.ts +0 -72
- package/dist/tm/bash-timeout.d.ts.map +0 -1
- package/dist/tm/bash-timeout.js +0 -100
- package/dist/tm/bash-timeout.js.map +0 -1
- package/dist/tm/browser.d.ts +0 -642
- package/dist/tm/browser.d.ts.map +0 -1
- package/dist/tm/browser.js +0 -2836
- package/dist/tm/browser.js.map +0 -1
- package/dist/tm/ptc/budgets.d.ts +0 -59
- package/dist/tm/ptc/budgets.d.ts.map +0 -1
- package/dist/tm/ptc/budgets.js +0 -138
- package/dist/tm/ptc/budgets.js.map +0 -1
- package/dist/tm/ptc/contract.d.ts +0 -171
- package/dist/tm/ptc/contract.d.ts.map +0 -1
- package/dist/tm/ptc/contract.js +0 -85
- package/dist/tm/ptc/contract.js.map +0 -1
- package/dist/tm/ptc/driver.d.ts +0 -40
- package/dist/tm/ptc/driver.d.ts.map +0 -1
- package/dist/tm/ptc/driver.js +0 -213
- package/dist/tm/ptc/driver.js.map +0 -1
- package/dist/tm/ptc/engines.d.ts +0 -43
- package/dist/tm/ptc/engines.d.ts.map +0 -1
- package/dist/tm/ptc/engines.js +0 -454
- package/dist/tm/ptc/engines.js.map +0 -1
- package/dist/tm/ptc/gate.d.ts +0 -28
- package/dist/tm/ptc/gate.d.ts.map +0 -1
- package/dist/tm/ptc/gate.js +0 -130
- package/dist/tm/ptc/gate.js.map +0 -1
- package/dist/tm/ptc/index.d.ts +0 -19
- package/dist/tm/ptc/index.d.ts.map +0 -1
- package/dist/tm/ptc/index.js +0 -15
- package/dist/tm/ptc/index.js.map +0 -1
- package/dist/tm/ptc/pscan.d.ts +0 -30
- package/dist/tm/ptc/pscan.d.ts.map +0 -1
- package/dist/tm/ptc/pscan.js +0 -341
- package/dist/tm/ptc/pscan.js.map +0 -1
- package/dist/tm/ptc/summary.d.ts +0 -17
- package/dist/tm/ptc/summary.d.ts.map +0 -1
- package/dist/tm/ptc/summary.js +0 -134
- package/dist/tm/ptc/summary.js.map +0 -1
- package/dist/tm/ptc/tool.d.ts +0 -66
- package/dist/tm/ptc/tool.d.ts.map +0 -1
- package/dist/tm/ptc/tool.js +0 -231
- package/dist/tm/ptc/tool.js.map +0 -1
- package/dist/tm/ptc.d.ts +0 -298
- package/dist/tm/ptc.d.ts.map +0 -1
- package/dist/tm/ptc.js +0 -865
- package/dist/tm/ptc.js.map +0 -1
- package/dist/tm/pty.d.ts +0 -70
- package/dist/tm/pty.d.ts.map +0 -1
- package/dist/tm/pty.js +0 -245
- package/dist/tm/pty.js.map +0 -1
- package/dist/tool-coerce.d.ts +0 -24
- package/dist/tool-coerce.d.ts.map +0 -1
- package/dist/tool-coerce.js +0 -63
- package/dist/tool-coerce.js.map +0 -1
package/dist/tm/browser.js
DELETED
|
@@ -1,2836 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* tm_browser — governed interactive browser (Plan C: the user's own
|
|
3
|
-
* Chromium-family browser, headful by default on desktops).
|
|
4
|
-
*
|
|
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, 18-verb chrome-devtools-mcp surface)
|
|
16
|
-
* require the playwright engine.
|
|
17
|
-
*
|
|
18
|
-
* Action surface aligns with chrome-devtools-mcp's 18 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.
|
|
48
|
-
*
|
|
49
|
-
* Environment adaptivity (different OpenCode hosts):
|
|
50
|
-
* - headful by default (Plan C); display-less Linux (no DISPLAY/
|
|
51
|
-
* WAYLAND_DISPLAY) automatically falls back to headless; TM_BROWSER_
|
|
52
|
-
* HEADLESS=1|0 forces either way;
|
|
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.
|
|
60
|
-
*
|
|
61
|
-
* Role access mirrors tm_webfetch: network roles (team + researcher) only,
|
|
62
|
-
* tester browser-only (UI verification).
|
|
63
|
-
*/
|
|
64
|
-
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
65
|
-
import * as fs from "node:fs";
|
|
66
|
-
import * as os from "node:os";
|
|
67
|
-
import * as path from "node:path";
|
|
68
|
-
import { checkWebUrl, seedWebfetchDomains } from "./webfetch.js";
|
|
69
|
-
import { askGrantNote, askRefusalNote, askUserForTarget, askUserForTargetDetailed } from "./perm-ask.js";
|
|
70
|
-
import { assertReadablePath } from "./guard.js";
|
|
71
|
-
import { isEnvFilePath } from "../envprotect.js";
|
|
72
|
-
import { createSerpLoopGuard, serpRefusal } from "./serp-loop.js";
|
|
73
|
-
import { tmError, toToolResult } from "./result.js";
|
|
74
|
-
import { estimateTokens } from "./config.js";
|
|
75
|
-
import { rmForceSafe } from "../fs-safe.js";
|
|
76
|
-
/**
|
|
77
|
-
* Per-process by design, like the dupe guard: ONE shared browser tool instance
|
|
78
|
-
* serves every agent in this plugin process, and "how many search-result pages
|
|
79
|
-
* has this conversation opened" is a conversation-level fact, not a per-caller
|
|
80
|
-
* one (leases are per-caller; the loop is the conversation's).
|
|
81
|
-
*/
|
|
82
|
-
const serpLoop = createSerpLoopGuard();
|
|
83
|
-
/** Per-OS browser candidates, in preference order (first hit wins). ONLY a
|
|
84
|
-
* fallback: on Windows the registry probe below is what actually honours the
|
|
85
|
-
* user's chosen channel. Edge/Chrome ship each channel in its OWN install
|
|
86
|
-
* directory (`Microsoft\EdgeBeta`, `EdgeDev`, `EdgeCanary`), so the stable
|
|
87
|
-
* path alone let a Beta-default host fall back to stable Edge (observed on
|
|
88
|
-
* Windows 11, 2026-09-18). */
|
|
89
|
-
const BROWSER_CANDIDATES = {
|
|
90
|
-
win32: [
|
|
91
|
-
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
92
|
-
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
93
|
-
"C:\\Program Files (x86)\\Microsoft\\Edge Beta\\Application\\msedge.exe",
|
|
94
|
-
"C:\\Program Files\\Microsoft\\Edge Beta\\Application\\msedge.exe",
|
|
95
|
-
"C:\\Program Files (x86)\\Microsoft\\Edge Dev\\Application\\msedge.exe",
|
|
96
|
-
"C:\\Program Files\\Microsoft\\Edge Dev\\Application\\msedge.exe",
|
|
97
|
-
"C:\\Program Files (x86)\\Microsoft\\Edge Canary\\Application\\msedge.exe",
|
|
98
|
-
"C:\\Program Files\\Microsoft\\Edge Canary\\Application\\msedge.exe",
|
|
99
|
-
"%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe",
|
|
100
|
-
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
101
|
-
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
102
|
-
"%LOCALAPPDATA%\\Google\\Chrome Beta\\Application\\chrome.exe",
|
|
103
|
-
"%LOCALAPPDATA%\\Google\\Chrome Dev\\Application\\chrome.exe",
|
|
104
|
-
"%LOCALAPPDATA%\\Google\\Chrome SxS\\Application\\chrome.exe",
|
|
105
|
-
],
|
|
106
|
-
darwin: [
|
|
107
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
108
|
-
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
109
|
-
"/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
|
|
110
|
-
"/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
|
|
111
|
-
"/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary",
|
|
112
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
113
|
-
],
|
|
114
|
-
linux: [
|
|
115
|
-
"/usr/bin/google-chrome",
|
|
116
|
-
"/usr/bin/google-chrome-stable",
|
|
117
|
-
"/usr/bin/google-chrome-beta",
|
|
118
|
-
"/usr/bin/google-chrome-unstable",
|
|
119
|
-
"/usr/bin/chromium-browser",
|
|
120
|
-
"/usr/bin/chromium",
|
|
121
|
-
"/snap/bin/chromium",
|
|
122
|
-
],
|
|
123
|
-
};
|
|
124
|
-
const defaultRunner = (cmd, args) => String(execFileSync(cmd, args, { encoding: "utf8", timeout: 3000, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }));
|
|
125
|
-
/** Chromium-family binaries only — --remote-debugging-pipe is a Chromium
|
|
126
|
-
* protocol; Firefox/WebKit as the default browser cannot drive tm_browser. */
|
|
127
|
-
const CHROMIUM_FAMILY_RE = /(?:^|[\\/])(?:chrome|msedge|brave|vivaldi|chromium|chromium-browser|opera|yandex)(?:\.exe)?$/i;
|
|
128
|
-
export function isChromiumFamily(exePath) {
|
|
129
|
-
return CHROMIUM_FAMILY_RE.test(String(exePath ?? "").trim());
|
|
130
|
-
}
|
|
131
|
-
/** Parse `reg query "...UrlAssociations\http\UserChoice" /v ProgId` output. */
|
|
132
|
-
export function parseProgId(stdout) {
|
|
133
|
-
const m = /ProgId\s+REG_SZ\s+(\S+)/i.exec(String(stdout ?? ""));
|
|
134
|
-
return m ? m[1] : null;
|
|
135
|
-
}
|
|
136
|
-
/** Parse `reg query "...\shell\open\command" /ve` output → exe path.
|
|
137
|
-
* The quoted capture stops at the closing quote, so the `" -- %1"` style
|
|
138
|
-
* URL-template tail never leaks into the returned path. */
|
|
139
|
-
export function parseRegCommand(stdout) {
|
|
140
|
-
const m = /REG_SZ\s+(?:"([^"]+)"|(\S+))/i.exec(String(stdout ?? ""));
|
|
141
|
-
return m ? (m[1] ?? m[2]) : null;
|
|
142
|
-
}
|
|
143
|
-
/** Parse the Exec= line of an xdg .desktop file. */
|
|
144
|
-
export function parseDesktopExec(text) {
|
|
145
|
-
const m = /^Exec\s*=\s*(?:"([^"]+)"|(\S+))/m.exec(String(text ?? ""));
|
|
146
|
-
return m ? (m[1] ?? m[2]) : null;
|
|
147
|
-
}
|
|
148
|
-
/** Registry roots that can carry a ProgId's `shell\open\command`. Edge is
|
|
149
|
-
* installed machine-wide (HKLM\SOFTWARE\Classes) while a per-user Chrome
|
|
150
|
-
* install registers HKCU\Software\Classes — probing HKCU only is what made
|
|
151
|
-
* a Beta-default host come back empty and fall through to the stable
|
|
152
|
-
* candidate path. */
|
|
153
|
-
const REG_CLASSES_ROOTS = ["HKCU\\Software\\Classes", "HKLM\\SOFTWARE\\Classes"];
|
|
154
|
-
/** Both URL schemes the shell association is recorded under. A user can
|
|
155
|
-
* set the two differently, so `http` alone is not "the default browser". */
|
|
156
|
-
const REG_URL_SCHEMES = ["http", "https"];
|
|
157
|
-
/** Channel install dirs keyed off the ProgId name (`MSEdgeBetaHTM`,
|
|
158
|
-
* `ChromeHTML`, …) — the last-resort probe when the launch command itself is
|
|
159
|
-
* unreadable but the ProgId still names the channel. NOTE the real Windows
|
|
160
|
-
* Edge dirs carry a SPACE ("Microsoft\Edge Beta"), which is what an
|
|
161
|
-
* `EdgeBeta` guess silently misses; and Chrome installs per-user under
|
|
162
|
-
* %LOCALAPPDATA% with a different exe name. */
|
|
163
|
-
const CHANNEL_INSTALL_BY_PROGID = [
|
|
164
|
-
[/edgebeta/i, "C:\\Program Files (x86)\\Microsoft\\Edge Beta\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge Beta\\Application\\msedge.exe"],
|
|
165
|
-
[/edgedev/i, "C:\\Program Files (x86)\\Microsoft\\Edge Dev\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge Dev\\Application\\msedge.exe"],
|
|
166
|
-
[/edgecanary|edgeappcanary/i, "C:\\Program Files (x86)\\Microsoft\\Edge Canary\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge Canary\\Application\\msedge.exe"],
|
|
167
|
-
[/msedge|edgehtm/i, "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"],
|
|
168
|
-
[/chromebeta/i, "%LOCALAPPDATA%\\Google\\Chrome Beta\\Application\\chrome.exe", "C:\\Program Files\\Google\\Chrome Beta\\Application\\chrome.exe"],
|
|
169
|
-
[/chromedev|chromejsx/i, "%LOCALAPPDATA%\\Google\\Chrome Dev\\Application\\chrome.exe", "C:\\Program Files\\Google\\Chrome Dev\\Application\\chrome.exe"],
|
|
170
|
-
[/chromehtml|chromechtml/i, "%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"],
|
|
171
|
-
];
|
|
172
|
-
/** Resolve a ProgId to its executable via every classes root. `reg query`
|
|
173
|
-
* exits non-zero (throws) for a missing key, so each probe is wrapped. */
|
|
174
|
-
function progIdToExecutable(progId, run) {
|
|
175
|
-
for (const root of REG_CLASSES_ROOTS) {
|
|
176
|
-
try {
|
|
177
|
-
const exe = parseRegCommand(run("reg", ["query", `${root}\\${progId}\\shell\\open\\command`, "/ve"]));
|
|
178
|
-
if (exe)
|
|
179
|
-
return exe;
|
|
180
|
-
}
|
|
181
|
-
catch {
|
|
182
|
-
/* next root */
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
/** The user's DEFAULT browser, when it is Chromium-family and present.
|
|
188
|
-
* null → the caller falls back to the per-OS probe list (detection is
|
|
189
|
-
* best-effort and must never throw into tool discovery). */
|
|
190
|
-
export function defaultBrowserExecutable(env = process.env, run = defaultRunner) {
|
|
191
|
-
try {
|
|
192
|
-
if (process.platform === "win32") {
|
|
193
|
-
const accept = (exe) => exe && isChromiumFamily(exe) && fs.existsSync(exe) ? exe : null;
|
|
194
|
-
for (const scheme of REG_URL_SCHEMES) {
|
|
195
|
-
let progId = null;
|
|
196
|
-
try {
|
|
197
|
-
progId = parseProgId(run("reg", [
|
|
198
|
-
"query",
|
|
199
|
-
`HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\${scheme}\\UserChoice`,
|
|
200
|
-
"/v",
|
|
201
|
-
"ProgId",
|
|
202
|
-
]));
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
progId = null;
|
|
206
|
-
}
|
|
207
|
-
if (!progId)
|
|
208
|
-
continue;
|
|
209
|
-
const direct = accept(progIdToExecutable(progId, run));
|
|
210
|
-
if (direct)
|
|
211
|
-
return direct;
|
|
212
|
-
// launch command unreadable (or points at a Firefox shim) — the
|
|
213
|
-
// ProgId itself still names the channel, so probe that channel's
|
|
214
|
-
// standard install dir before giving up on the user's default.
|
|
215
|
-
for (const [re, ...cands] of CHANNEL_INSTALL_BY_PROGID) {
|
|
216
|
-
if (!re.test(progId))
|
|
217
|
-
continue;
|
|
218
|
-
for (const cand of cands) {
|
|
219
|
-
const hit = accept(cand.replace(/%LOCALAPPDATA%/gi, String(env.LOCALAPPDATA ?? "").trim()));
|
|
220
|
-
if (hit)
|
|
221
|
-
return hit;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
if (process.platform === "linux") {
|
|
228
|
-
const desk = run("xdg-settings", ["get", "default-web-browser"]).trim();
|
|
229
|
-
if (!desk)
|
|
230
|
-
return null;
|
|
231
|
-
const name = desk.endsWith(".desktop") ? desk : desk + ".desktop";
|
|
232
|
-
const dirs = [
|
|
233
|
-
"/usr/share/applications",
|
|
234
|
-
"/usr/local/share/applications",
|
|
235
|
-
path.join(String(env.HOME ?? ""), ".local/share/applications"),
|
|
236
|
-
];
|
|
237
|
-
for (const dir of dirs) {
|
|
238
|
-
const f = path.join(dir, name);
|
|
239
|
-
if (!fs.existsSync(f))
|
|
240
|
-
continue;
|
|
241
|
-
const exe = parseDesktopExec(fs.readFileSync(f, "utf8"));
|
|
242
|
-
if (exe && isChromiumFamily(exe) && fs.existsSync(exe))
|
|
243
|
-
return exe;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
248
|
-
/* best-effort — the probe list takes over */
|
|
249
|
-
}
|
|
250
|
-
return null;
|
|
251
|
-
}
|
|
252
|
-
/** Playwright's `channel` launch IGNORES the executable we discovered —
|
|
253
|
-
* `channel:"msedge"` resolves whatever playwright thinks the stable Edge is,
|
|
254
|
-
* so a correct Edge-Beta discovery still opened stable (the decisive half of
|
|
255
|
-
* the 2026-09-18 report). The channel spelling is therefore only correct
|
|
256
|
-
* for the exact stable-channel install dir; everything else (Beta/Dev/
|
|
257
|
-
* Canary, portable, an explicit TM_BROWSER_PATH) rides on executablePath. */
|
|
258
|
-
export function playwrightLaunchTarget(executable, opts = {}) {
|
|
259
|
-
const exe = String(executable ?? "");
|
|
260
|
-
const p = exe.replace(/\//g, "\\").toLowerCase();
|
|
261
|
-
if (!opts.explicitOverride) {
|
|
262
|
-
if (/\\microsoft\\edge\\application\\msedge\.exe$/.test(p))
|
|
263
|
-
return { executablePath: exe, channel: "msedge" };
|
|
264
|
-
if (/\\google\\chrome\\application\\chrome\.exe$/.test(p))
|
|
265
|
-
return { executablePath: exe, channel: "chrome" };
|
|
266
|
-
}
|
|
267
|
-
return { executablePath: exe };
|
|
268
|
-
}
|
|
269
|
-
/** Resolve the browser executable: TM_BROWSER_PATH override → the user's
|
|
270
|
-
* DEFAULT browser (Chromium-family only) → per-OS candidate paths.
|
|
271
|
-
* %LOCALAPPDATA% style vars are expanded on win32. */
|
|
272
|
-
export function findBrowserExecutable(env = process.env) {
|
|
273
|
-
const override = String(env.TM_BROWSER_PATH ?? "").trim();
|
|
274
|
-
const tryPath = (p) => {
|
|
275
|
-
const expanded = p.replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (_, name) => env[name] ?? "");
|
|
276
|
-
try {
|
|
277
|
-
return fs.existsSync(expanded) ? expanded : null;
|
|
278
|
-
}
|
|
279
|
-
catch {
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
};
|
|
283
|
-
if (override)
|
|
284
|
-
return tryPath(override);
|
|
285
|
-
const def = defaultBrowserExecutable(env);
|
|
286
|
-
if (def)
|
|
287
|
-
return def;
|
|
288
|
-
for (const c of BROWSER_CANDIDATES[process.platform] ?? []) {
|
|
289
|
-
const hit = tryPath(c);
|
|
290
|
-
if (hit)
|
|
291
|
-
return hit;
|
|
292
|
-
}
|
|
293
|
-
return null;
|
|
294
|
-
}
|
|
295
|
-
/**
|
|
296
|
-
* Headless resolution (Plan C = headful by default):
|
|
297
|
-
* TM_BROWSER_HEADLESS=1|true|force → headless (servers/CI)
|
|
298
|
-
* TM_BROWSER_HEADLESS=0|false|never → headful
|
|
299
|
-
* auto (default) → headless ONLY when there is no
|
|
300
|
-
* display possible (Linux without DISPLAY/WAYLAND_DISPLAY).
|
|
301
|
-
*/
|
|
302
|
-
/** Decide what profile directory TM_BROWSER_USER_DATA_DIR asks for.
|
|
303
|
-
*
|
|
304
|
-
* The tool description has claimed for a long time that "the real profile is
|
|
305
|
-
* never touched" while nothing enforced it: the string went straight into
|
|
306
|
-
* `launchPersistentContext`. Pointing it at the user's own browser data dir is
|
|
307
|
-
* not a neutral choice — with the browser open, the new process hands its URL
|
|
308
|
-
* to the running instance and exits (the confusing "本机已有同品牌浏览器在跑"
|
|
309
|
-
* failure), and with it closed the agent browses AS THE USER, with every cookie
|
|
310
|
-
* and saved session, while our orphan reaper force-kills (`taskkill /T /F`) any
|
|
311
|
-
* browser whose owner died — a real route to corrupting a profile.
|
|
312
|
-
*
|
|
313
|
-
* So: a real browser data dir is refused with the fix named, anything else is
|
|
314
|
-
* honored, and an unset value keeps the isolated temp profile (the default).
|
|
315
|
-
* Pure, so both engines and the tests share one answer. */
|
|
316
|
-
export function classifyProfileDir(env = process.env) {
|
|
317
|
-
const raw = String(env.TM_BROWSER_USER_DATA_DIR ?? "").trim();
|
|
318
|
-
if (!raw)
|
|
319
|
-
return { dir: null, refusal: null };
|
|
320
|
-
const norm = raw.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
321
|
-
const segs = norm.split("/").filter(Boolean);
|
|
322
|
-
const last = segs[segs.length - 1] ?? "";
|
|
323
|
-
const looksReal = last === "user data" ||
|
|
324
|
-
last === "google-chrome" ||
|
|
325
|
-
last === "chromium" ||
|
|
326
|
-
last === "profiles" ||
|
|
327
|
-
/(^|\/)(microsoft|google)\/(edge|chrome|chromium|chrome sxs)(\/(user data))?$/.test(norm) ||
|
|
328
|
-
/\/mozilla\/firefox\/(profiles|profile)$/.test(norm);
|
|
329
|
-
if (looksReal) {
|
|
330
|
-
return {
|
|
331
|
-
dir: null,
|
|
332
|
-
refusal: `TM_BROWSER_USER_DATA_DIR 指向的看起来是浏览器自己的数据目录(${raw}),拒绝用它启动。` +
|
|
333
|
-
`那样 agent 就是以你的真实身份上网,而孤儿回收是强杀进程,可能损坏这个目录。` +
|
|
334
|
-
`要保留登录态:把它改成一个专用空目录(例如 D:\\tm-browser-profile 或 ~/.tm-browser-profile),` +
|
|
335
|
-
`第一次让 agent 打开登录页、你人工登录一次,之后 cookie 就在这个目录里跨会话保留。`,
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
return { dir: raw, refusal: null };
|
|
339
|
-
}
|
|
340
|
-
export function resolveHeadless(env = process.env) {
|
|
341
|
-
const raw = String(env.TM_BROWSER_HEADLESS ?? "auto").trim().toLowerCase();
|
|
342
|
-
if (["1", "true", "force", "yes"].includes(raw))
|
|
343
|
-
return true;
|
|
344
|
-
if (["0", "false", "never", "no"].includes(raw))
|
|
345
|
-
return false;
|
|
346
|
-
if (process.platform === "linux")
|
|
347
|
-
return !env.DISPLAY && !env.WAYLAND_DISPLAY;
|
|
348
|
-
return false;
|
|
349
|
-
}
|
|
350
|
-
// ---------- subresource gate (the "pages render without images" fix) --------
|
|
351
|
-
/** A resource the page cannot execute code with — it can only paint. */
|
|
352
|
-
const PASSIVE_RESOURCE_TYPES = new Set(["image", "media", "font", "stylesheet"]);
|
|
353
|
-
/** Public suffixes that make the last TWO labels insufficient for a site
|
|
354
|
-
* comparison. Deliberately a small spelled-out list (no PSL dependency):
|
|
355
|
-
* an unlisted multi-label suffix yields a LONGER site key, which auto-passes
|
|
356
|
-
* FEWER hosts — the conservative direction for a network gate. */
|
|
357
|
-
const TWO_LABEL_SUFFIXES = new Set([
|
|
358
|
-
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn", "co.cn", "com.hk", "org.hk", "co.jp", "ne.jp",
|
|
359
|
-
"co.kr", "co.uk", "org.uk", "ac.uk", "com.au", "net.au", "org.au", "co.in", "com.br", "com.mx", "com.tr",
|
|
360
|
-
"com.ua", "com.sg", "com.my", "com.tw", "co.za", "com.pl", "com.ru", "com.vn", "com.id", "com.ar", "com.co",
|
|
361
|
-
]);
|
|
362
|
-
/** Approximate registrable site (eTLD+1) of a hostname. */
|
|
363
|
-
export function siteOf(host) {
|
|
364
|
-
const h = String(host ?? "").toLowerCase().replace(/\.$/, "");
|
|
365
|
-
if (!h || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(h))
|
|
366
|
-
return h;
|
|
367
|
-
const labels = h.split(".").filter(Boolean);
|
|
368
|
-
if (labels.length <= 2)
|
|
369
|
-
return labels.join(".");
|
|
370
|
-
const lastTwo = labels.slice(-2).join(".");
|
|
371
|
-
if (TWO_LABEL_SUFFIXES.has(lastTwo))
|
|
372
|
-
return labels.slice(-3).join(".");
|
|
373
|
-
return lastTwo;
|
|
374
|
-
}
|
|
375
|
-
/** playwright and CDP spell resource types differently (`Image` vs `image`,
|
|
376
|
-
* `Stylesheet` vs `stylesheet`) — one normalizer keeps both engines on the
|
|
377
|
-
* same verdict table. */
|
|
378
|
-
export function normalizeResourceType(raw) {
|
|
379
|
-
const t = String(raw ?? "").trim().toLowerCase();
|
|
380
|
-
return t === "" ? "other" : t;
|
|
381
|
-
}
|
|
382
|
-
export function isPassiveResource(raw) {
|
|
383
|
-
return PASSIVE_RESOURCE_TYPES.has(normalizeResourceType(raw));
|
|
384
|
-
}
|
|
385
|
-
/**
|
|
386
|
-
* One subresource verdict, shared VERBATIM by both engines so playwright and
|
|
387
|
-
* cdp-legacy can never drift. `allowedSites` holds the registrable sites of
|
|
388
|
-
* every host this session actually navigated to (allowlist hit or dialog
|
|
389
|
-
* approval), which is what makes a page's own JS/CSS work without opening
|
|
390
|
-
* the whole internet: the governance boundary moves from "21 content domains"
|
|
391
|
-
* to "the site you approved", and an off-site SCRIPT still needs a dialog.
|
|
392
|
-
*/
|
|
393
|
-
export function subresourcePass(opts) {
|
|
394
|
-
if (opts.allowlistHit)
|
|
395
|
-
return { pass: true, via: "allowlist" };
|
|
396
|
-
if (opts.approvedHost)
|
|
397
|
-
return { pass: true, via: "approved" };
|
|
398
|
-
if (opts.policy === "off")
|
|
399
|
-
return { pass: false, via: "blocked" };
|
|
400
|
-
let host;
|
|
401
|
-
try {
|
|
402
|
-
host = new URL(opts.url).hostname;
|
|
403
|
-
}
|
|
404
|
-
catch {
|
|
405
|
-
return { pass: false, via: "blocked" };
|
|
406
|
-
}
|
|
407
|
-
const type = normalizeResourceType(opts.resourceType);
|
|
408
|
-
if (PASSIVE_RESOURCE_TYPES.has(type))
|
|
409
|
-
return { pass: true, via: `passive:${type}` };
|
|
410
|
-
if (opts.policy === "passive")
|
|
411
|
-
return { pass: false, via: `blocked:${type}` };
|
|
412
|
-
return opts.allowedSites.has(siteOf(host)) ? { pass: true, via: `same-site:${type}` } : { pass: false, via: `blocked:${type}` };
|
|
413
|
-
}
|
|
414
|
-
/** A navigation (or a host the dialog approved) widens the subresource
|
|
415
|
-
* footprint to its own site. Called for EVERY top-level/document request
|
|
416
|
-
* that passed the allowlist side. */
|
|
417
|
-
export function rememberSite(allowedSites, url) {
|
|
418
|
-
try {
|
|
419
|
-
const host = new URL(url).hostname;
|
|
420
|
-
if (host)
|
|
421
|
-
allowedSites.add(siteOf(host));
|
|
422
|
-
}
|
|
423
|
-
catch {
|
|
424
|
-
/* unparseable — nothing to remember */
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
// ---------- a page that is not a page, but a wall ----------------------------
|
|
428
|
-
/**
|
|
429
|
-
* Human-verification walls. This exists because "0 个可寻址节点" and "this
|
|
430
|
-
* site wants a human to prove it is one" look identical to the agent, and the
|
|
431
|
-
* two have opposite next moves: an empty page is worth retrying, a wall never
|
|
432
|
-
* is — retrying just spends rounds on a captcha. Measured on baike.baidu.com
|
|
433
|
-
* item pages, where every arm (raw playwright included, all resources allowed)
|
|
434
|
-
* returns 0–6 nodes titled 百度安全验证, and the agent reported "该网站没有内容".
|
|
435
|
-
*
|
|
436
|
-
* Deliberately narrow: signatures we observed live, plus the unambiguous
|
|
437
|
-
* English ones. Callers consult it ONLY when the page came back thin, so an
|
|
438
|
-
* article that merely mentions 验证码 is never mislabelled as a wall.
|
|
439
|
-
*/
|
|
440
|
-
export const CHALLENGE_WALL_SIGNS = [
|
|
441
|
-
{ re: /百度安全验证|百度的安全验证/, label: "百度安全验证" },
|
|
442
|
-
{ re: /安全验证|访问验证|请完成验证|滑动验证|拖动滑块|人机验证|点击按钮进行验证/, label: "站点安全验证" },
|
|
443
|
-
{ re: /just a moment|checking before the link|cf-browser-verification/i, label: "Cloudflare 人机校验" },
|
|
444
|
-
{ re: /verify you are a human|are you a robot|enable javascript and cookies/i, label: "人机校验" },
|
|
445
|
-
{ re: /access denied|attention required|blocked because|request blocked/i, label: "访问被拒(反爬拦截)" },
|
|
446
|
-
];
|
|
447
|
-
/** Which wall this thin page actually is, or null when it is just empty. */
|
|
448
|
-
export function challengeWallOf(...texts) {
|
|
449
|
-
const hay = texts.filter(Boolean).join("\n").slice(0, 4000);
|
|
450
|
-
if (!hay.trim())
|
|
451
|
-
return null;
|
|
452
|
-
for (const s of CHALLENGE_WALL_SIGNS)
|
|
453
|
-
if (s.re.test(hay))
|
|
454
|
-
return s.label;
|
|
455
|
-
return null;
|
|
456
|
-
}
|
|
457
|
-
/** One hostname for `allow_host`, or "" when the argument is not a single bare
|
|
458
|
-
* domain. Strict on purpose: this value becomes the pattern in an official
|
|
459
|
-
* consent dialog, and a user approving "bkssl.bdimg.com" must not be getting a
|
|
460
|
-
* wildcard, a URL with a path, or a port. */
|
|
461
|
-
export function normalizeApprovalHost(raw) {
|
|
462
|
-
const s = String(raw ?? "").trim().toLowerCase().replace(/^[a-z]+:\/\//, "").replace(/^\.+|\.+$/g, "");
|
|
463
|
-
if (!s || s.length > 253)
|
|
464
|
-
return "";
|
|
465
|
-
if (/[\s/@:*?#%\\]/.test(s))
|
|
466
|
-
return "";
|
|
467
|
-
if (!/^[a-z0-9一-鿿-]+(\.[a-z0-9一-鿿-]+)+$/.test(s))
|
|
468
|
-
return "";
|
|
469
|
-
return s;
|
|
470
|
-
}
|
|
471
|
-
/** The document title, or "" — a title is a diagnostic, never a reason for the
|
|
472
|
-
* action that was asked for to fail. */
|
|
473
|
-
async function readTitle(page) {
|
|
474
|
-
try {
|
|
475
|
-
return String((await page.title?.()) ?? "");
|
|
476
|
-
}
|
|
477
|
-
catch {
|
|
478
|
-
return "";
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
/**
|
|
482
|
-
* The launch pid, asked more than once.
|
|
483
|
-
*
|
|
484
|
-
* Why this exists: `childProcRows` folds EVERY OS-query failure (PowerShell
|
|
485
|
-
* exiting non-zero, the 6 s timeout killing it mid-flight, a JSON parse miss)
|
|
486
|
-
* into an empty list, and the launcher used to ask exactly once. Under load —
|
|
487
|
-
* this plugin's own parallel test runner reproduces it, four suites hammering
|
|
488
|
-
* CIM at once — one such query fails now and then, and a single failed query
|
|
489
|
-
* used to cost the session two things permanently: the orphan-ledger entry (so
|
|
490
|
-
* the next boot could not reclaim that browser) and `close`'s process
|
|
491
|
-
* verification. Worse, an exception escaping the lookup failed `open` itself,
|
|
492
|
-
* reporting a browser that had launched fine as an error.
|
|
493
|
-
*
|
|
494
|
-
* So: a failed or empty answer is retried a bounded number of times, and any
|
|
495
|
-
* throw is swallowed into the retry counter — the pid is diagnostic and
|
|
496
|
-
* reclamation metadata, never a reason to lose the session. When it still
|
|
497
|
-
* cannot be resolved, `via: "none"` says so and close keeps refusing the
|
|
498
|
-
* 已确认关闭 sentence (pinned by §25).
|
|
499
|
-
*/
|
|
500
|
-
export const LAUNCH_PID_ATTEMPTS = 3;
|
|
501
|
-
export const LAUNCH_PID_RETRY_MS = 250;
|
|
502
|
-
export async function resolveLaunchPid(opts) {
|
|
503
|
-
const pwPid = Number(opts.playwrightPid?.() ?? 0) || 0;
|
|
504
|
-
if (pwPid > 0)
|
|
505
|
-
return { pid: pwPid, via: "playwright", failures: 0 };
|
|
506
|
-
const attempts = Math.max(1, Number(opts.attempts) || LAUNCH_PID_ATTEMPTS);
|
|
507
|
-
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
508
|
-
let failures = 0;
|
|
509
|
-
for (let i = 0; i < attempts; i++) {
|
|
510
|
-
if (i)
|
|
511
|
-
await sleep(LAUNCH_PID_RETRY_MS);
|
|
512
|
-
try {
|
|
513
|
-
const scan = launchedBrowserPid(opts.executablePath, { rows: opts.rows() });
|
|
514
|
-
if (scan > 0)
|
|
515
|
-
return { pid: scan, via: "child-scan", failures };
|
|
516
|
-
}
|
|
517
|
-
catch {
|
|
518
|
-
failures++;
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
return { pid: 0, via: "none", failures };
|
|
522
|
-
}
|
|
523
|
-
// ---------- evaluate_script: consent + result redaction ---------------------
|
|
524
|
-
/** `evaluate_script` is the one browser verb the network gate cannot cover:
|
|
525
|
-
* the allowlist limits where we NAVIGATE, not what an already-loaded page
|
|
526
|
-
* hands back. In a `TM_BROWSER_USER_DATA_DIR` profile that page may be a
|
|
527
|
-
* signed-in session, and `document.cookie` / `localStorage` / a token in the
|
|
528
|
-
* DOM is one expression away — and its value would then ride into the model
|
|
529
|
-
* context, the run store and the trajectory. So: ask once per browser
|
|
530
|
-
* session (the OFFICIAL dialog), and name-anchor the result on the shapes
|
|
531
|
-
* that are secrets, not on "looks random" (which would blank legitimate
|
|
532
|
-
* JSON and teach the agent to distrust every read). */
|
|
533
|
-
export const EVAL_SECRET_SHAPES = [
|
|
534
|
-
{ name: "JWT", re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
535
|
-
{ name: "bearer", re: /\b(bearer\s+[A-Za-z0-9._~+/-]{12,}=*)/gi },
|
|
536
|
-
{ name: "auth-header", re: /\b(authorization\s*[:=]\s*[^\s"',}]{8,})/gi },
|
|
537
|
-
{ name: "cookie", re: /\b(set-cookie|cookie)\s*[:=]\s*[^"',}\n]{6,}/gi },
|
|
538
|
-
{ name: "api-key", re: /\b(sk|ghp|gho|github_pat|xox[baprs]|AKIA|AIza)[A-Za-z0-9_-]{8,}\b/g },
|
|
539
|
-
{ name: "secret-pair", re: /\b((?:password|passwd|secret|api_?key|access_?token|refresh_?token|client_?secret)\s*[:=]\s*"?[^\s"',}]{4,})/gi },
|
|
540
|
-
];
|
|
541
|
-
/** Mask every known secret shape in an in-page result. Returns the masked
|
|
542
|
-
* text plus WHICH kinds were masked (counted, never the values). */
|
|
543
|
-
export function redactEvalResult(text) {
|
|
544
|
-
let out = text;
|
|
545
|
-
const kinds = [];
|
|
546
|
-
for (const shape of EVAL_SECRET_SHAPES) {
|
|
547
|
-
const re = new RegExp(shape.re.source, shape.re.flags);
|
|
548
|
-
let hits = 0;
|
|
549
|
-
out = out.replace(re, () => {
|
|
550
|
-
hits++;
|
|
551
|
-
return `〔已脱敏:${shape.name}〕`;
|
|
552
|
-
});
|
|
553
|
-
if (hits)
|
|
554
|
-
kinds.push(`${shape.name}×${hits}`);
|
|
555
|
-
}
|
|
556
|
-
return { text: out, masked: kinds };
|
|
557
|
-
}
|
|
558
|
-
export function browserLedgerFile(storeRoot) {
|
|
559
|
-
return path.join(storeRoot, "browsers.jsonl");
|
|
560
|
-
}
|
|
561
|
-
/** Best-effort append: a ledger that cannot be written must never cost the
|
|
562
|
-
* user their browser session. */
|
|
563
|
-
export function appendBrowserLedger(file, rec) {
|
|
564
|
-
try {
|
|
565
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
566
|
-
fs.appendFileSync(file, JSON.stringify(rec) + "\n", "utf8");
|
|
567
|
-
}
|
|
568
|
-
catch {
|
|
569
|
-
/* observability + reclamation only */
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
/** Read the ledger, terminate the orphans, and rewrite what is still live.
|
|
573
|
-
* Returns the counts so the caller can log them; never throws. */
|
|
574
|
-
export function reapOrphanBrowsers(file, opts = {}) {
|
|
575
|
-
const alive = opts.alive ?? pidAlive;
|
|
576
|
-
const kill = opts.kill ?? ((p) => killBrowserTree(p));
|
|
577
|
-
const identity = opts.identity ?? procIdentity;
|
|
578
|
-
const out = { reaped: [], kept: 0, dropped: 0 };
|
|
579
|
-
let raw;
|
|
580
|
-
try {
|
|
581
|
-
raw = fs.readFileSync(file, "utf8");
|
|
582
|
-
}
|
|
583
|
-
catch {
|
|
584
|
-
return out;
|
|
585
|
-
}
|
|
586
|
-
const survivors = [];
|
|
587
|
-
const seen = new Set();
|
|
588
|
-
for (const line of raw.split("\n")) {
|
|
589
|
-
if (!line.trim())
|
|
590
|
-
continue;
|
|
591
|
-
let e;
|
|
592
|
-
try {
|
|
593
|
-
e = JSON.parse(line);
|
|
594
|
-
}
|
|
595
|
-
catch {
|
|
596
|
-
continue; // a torn line is not an instruction to kill anything
|
|
597
|
-
}
|
|
598
|
-
const pid = Number(e?.pid);
|
|
599
|
-
const owner = Number(e?.ownerPid);
|
|
600
|
-
if (!Number.isFinite(pid) || pid <= 0 || seen.has(pid)) {
|
|
601
|
-
out.dropped++;
|
|
602
|
-
continue;
|
|
603
|
-
}
|
|
604
|
-
seen.add(pid);
|
|
605
|
-
if (!alive(pid)) {
|
|
606
|
-
out.dropped++; // browser exited on its own — the entry is history
|
|
607
|
-
continue;
|
|
608
|
-
}
|
|
609
|
-
if (owner > 0 && !alive(owner)) {
|
|
610
|
-
// An OS pid is a recyclable number, and the kill below is a FORCE kill of
|
|
611
|
-
// a whole process tree. So the entry has to still describe the
|
|
612
|
-
// executable we launched: if it does not, that pid belongs to an
|
|
613
|
-
// unrelated program now and killing it would be our bug, not our job.
|
|
614
|
-
const exe = String(e?.exe ?? "").trim();
|
|
615
|
-
if (exe && !identityMatchesExecutable(exe, identity(pid))) {
|
|
616
|
-
out.dropped++;
|
|
617
|
-
continue;
|
|
618
|
-
}
|
|
619
|
-
try {
|
|
620
|
-
kill(pid);
|
|
621
|
-
out.reaped.push(pid);
|
|
622
|
-
}
|
|
623
|
-
catch {
|
|
624
|
-
survivors.push(e); // not ours to signal (or already gone) — keep the record, say nothing false
|
|
625
|
-
}
|
|
626
|
-
continue;
|
|
627
|
-
}
|
|
628
|
-
survivors.push(e);
|
|
629
|
-
}
|
|
630
|
-
out.kept = survivors.length;
|
|
631
|
-
try {
|
|
632
|
-
if (survivors.length)
|
|
633
|
-
fs.writeFileSync(file, survivors.map((s) => JSON.stringify(s) + "\n").join(""), "utf8");
|
|
634
|
-
else
|
|
635
|
-
fs.rmSync(file, { force: true });
|
|
636
|
-
}
|
|
637
|
-
catch {
|
|
638
|
-
/* the next boot retries */
|
|
639
|
-
}
|
|
640
|
-
return out;
|
|
641
|
-
}
|
|
642
|
-
/** Is this OS pid still alive? `process.kill(pid, 0)` is the portable probe:
|
|
643
|
-
* ESRCH means gone, EPERM means alive but owned by someone else — both are
|
|
644
|
-
* answers, and "no answer" is never one of them. */
|
|
645
|
-
export function pidAlive(pid) {
|
|
646
|
-
if (!Number.isFinite(pid) || pid <= 0)
|
|
647
|
-
return false;
|
|
648
|
-
try {
|
|
649
|
-
process.kill(pid, 0);
|
|
650
|
-
return true;
|
|
651
|
-
}
|
|
652
|
-
catch (err) {
|
|
653
|
-
return err?.code === "EPERM";
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
/** Wait up to `ms` for a pid to disappear, then terminate it once and give it
|
|
657
|
-
* a short grace. Returns whether the process is GONE — the only honest basis
|
|
658
|
-
* for a "已确认关闭" claim. */
|
|
659
|
-
export async function waitForPidExit(pid, ms = 3000, kill = (p) => killBrowserTree(p)) {
|
|
660
|
-
if (!pidAlive(pid))
|
|
661
|
-
return true;
|
|
662
|
-
const deadline = Date.now() + Math.max(0, ms);
|
|
663
|
-
while (Date.now() < deadline && pidAlive(pid)) {
|
|
664
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
665
|
-
}
|
|
666
|
-
if (!pidAlive(pid))
|
|
667
|
-
return true;
|
|
668
|
-
try {
|
|
669
|
-
kill(pid);
|
|
670
|
-
}
|
|
671
|
-
catch {
|
|
672
|
-
/* already gone, or not ours to signal — the re-check below decides */
|
|
673
|
-
}
|
|
674
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
675
|
-
return !pidAlive(pid);
|
|
676
|
-
}
|
|
677
|
-
/** What a pid actually is right now — its image name and command line, or null
|
|
678
|
-
* when the pid is gone. Used to prove a recorded pid is still the browser we
|
|
679
|
-
* launched before anything force-kills it (see reapOrphanBrowsers). */
|
|
680
|
-
function procIdentity(pid) {
|
|
681
|
-
const p = Number(pid);
|
|
682
|
-
if (!Number.isInteger(p) || p <= 0)
|
|
683
|
-
return null;
|
|
684
|
-
if (process.platform === "win32") {
|
|
685
|
-
const script = `Get-CimInstance Win32_Process -Filter 'ProcessId=${p}' | Select-Object Name,CommandLine | ConvertTo-Json -Compress`;
|
|
686
|
-
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
687
|
-
encoding: "utf8",
|
|
688
|
-
windowsHide: true,
|
|
689
|
-
timeout: 6000,
|
|
690
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
691
|
-
});
|
|
692
|
-
if (r.status !== 0)
|
|
693
|
-
return null;
|
|
694
|
-
const text = String(r.stdout ?? "").trim();
|
|
695
|
-
if (!text)
|
|
696
|
-
return null;
|
|
697
|
-
try {
|
|
698
|
-
const row = JSON.parse(text);
|
|
699
|
-
const one = (Array.isArray(row) ? row[0] : row) ?? {};
|
|
700
|
-
return { name: String(one?.Name ?? ""), cmdline: String(one?.CommandLine ?? "") };
|
|
701
|
-
}
|
|
702
|
-
catch {
|
|
703
|
-
return null;
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
try {
|
|
707
|
-
const raw = fs.readFileSync(`/proc/${p}/cmdline`);
|
|
708
|
-
const parts = String(raw).split("\0").filter((s) => s !== "");
|
|
709
|
-
if (!parts.length)
|
|
710
|
-
return null;
|
|
711
|
-
return { name: path.basename(parts[0]), cmdline: parts.join(" ") };
|
|
712
|
-
}
|
|
713
|
-
catch {
|
|
714
|
-
return null;
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
/** Does this identity look like the executable we launched? Compared on the
|
|
718
|
-
* file NAME: the command line's quoting and path form can differ from ours
|
|
719
|
-
* (8.3-short vs long path, escaped quotes), while a pid recycled by an
|
|
720
|
-
* unrelated program almost never carries the same image name. A missing
|
|
721
|
-
* identity counts as NO — the safe answer before a force-kill. */
|
|
722
|
-
export function identityMatchesExecutable(exePath, ident) {
|
|
723
|
-
const want = path.basename(String(exePath ?? "")).toLowerCase();
|
|
724
|
-
if (!want || !ident)
|
|
725
|
-
return false;
|
|
726
|
-
const name = String(ident.name ?? "").toLowerCase();
|
|
727
|
-
const cmd = String(ident.cmdline ?? "").toLowerCase();
|
|
728
|
-
return name === want || cmd.includes(want);
|
|
729
|
-
}
|
|
730
|
-
/** The same question asked of a live pid (see identityMatchesExecutable). */
|
|
731
|
-
export function pidMatchesExecutable(pid, exePath) {
|
|
732
|
-
return identityMatchesExecutable(exePath, procIdentity(pid));
|
|
733
|
-
}
|
|
734
|
-
/** Terminate a browser process AND everything it spawned.
|
|
735
|
-
*
|
|
736
|
-
* `process.kill(pid)` signals the ROOT only. Measured on the desktop host the
|
|
737
|
-
* day this was written: the orphan reaper's default kill took the msedge root
|
|
738
|
-
* down and left all NINE of its child processes running — which is exactly the
|
|
739
|
-
* leftover the reaper exists to remove. The graceful path does not have this
|
|
740
|
-
* problem (playwright's `browser.close()` shuts its children down first, and
|
|
741
|
-
* the real-host leg verifies it), so this is the fallback's job.
|
|
742
|
-
*
|
|
743
|
-
* win32: `taskkill /T /F` walks the tree for us.
|
|
744
|
-
* POSIX: no tree flag, so descendants are enumerated and killed deepest-first.
|
|
745
|
-
* Best-effort throughout: a process that vanishes mid-walk is not an error.
|
|
746
|
-
*/
|
|
747
|
-
export function killBrowserTree(pid, opts = {}) {
|
|
748
|
-
const p = Number(pid);
|
|
749
|
-
if (!Number.isInteger(p) || p <= 0)
|
|
750
|
-
return;
|
|
751
|
-
const run = opts.run ?? ((cmd, args) => {
|
|
752
|
-
spawnSync(cmd, args, { windowsHide: true, timeout: 8000 });
|
|
753
|
-
});
|
|
754
|
-
if ((opts.platform ?? process.platform) === "win32") {
|
|
755
|
-
try {
|
|
756
|
-
run("taskkill.exe", ["/PID", String(p), "/T", "/F"]);
|
|
757
|
-
}
|
|
758
|
-
catch {
|
|
759
|
-
/* gone already, or not ours to signal */
|
|
760
|
-
}
|
|
761
|
-
return;
|
|
762
|
-
}
|
|
763
|
-
const children = opts.children ?? childProcRows;
|
|
764
|
-
const order = [];
|
|
765
|
-
const queue = [p];
|
|
766
|
-
while (queue.length) {
|
|
767
|
-
const cur = queue.shift();
|
|
768
|
-
let kids = [];
|
|
769
|
-
try {
|
|
770
|
-
kids = children(cur) ?? [];
|
|
771
|
-
}
|
|
772
|
-
catch {
|
|
773
|
-
kids = [];
|
|
774
|
-
}
|
|
775
|
-
for (const k of kids) {
|
|
776
|
-
const kid = Number(k?.pid);
|
|
777
|
-
if (Number.isInteger(kid) && kid > 0 && kid !== p && !order.includes(kid)) {
|
|
778
|
-
order.push(kid);
|
|
779
|
-
queue.push(kid);
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
const signal = opts.signal ?? ((p) => process.kill(p));
|
|
784
|
-
for (const kid of order.reverse()) {
|
|
785
|
-
try {
|
|
786
|
-
signal(kid);
|
|
787
|
-
}
|
|
788
|
-
catch {
|
|
789
|
-
/* racy by design — the re-check above is what decides */
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
try {
|
|
793
|
-
signal(p);
|
|
794
|
-
}
|
|
795
|
-
catch {
|
|
796
|
-
/* already gone */
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
/** Children of `ppid`, straight from the OS. Best-effort by design: a host
|
|
800
|
-
* without PowerShell/CIM or a refused query returns [], and the caller then
|
|
801
|
-
* reports that the process could not be verified rather than guessing. */
|
|
802
|
-
function childProcRows(ppid) {
|
|
803
|
-
if (process.platform === "win32") {
|
|
804
|
-
// The filter runs server-side, so the reply holds ONLY our own children —
|
|
805
|
-
// never a full-table scan, never a name match on someone else's browser.
|
|
806
|
-
const script = `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${ppid}' | ` +
|
|
807
|
-
"Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress";
|
|
808
|
-
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
809
|
-
encoding: "utf8",
|
|
810
|
-
windowsHide: true,
|
|
811
|
-
timeout: 6000,
|
|
812
|
-
maxBuffer: 8 * 1024 * 1024,
|
|
813
|
-
});
|
|
814
|
-
if (r.status !== 0)
|
|
815
|
-
return [];
|
|
816
|
-
const text = String(r.stdout ?? "").trim();
|
|
817
|
-
if (!text)
|
|
818
|
-
return [];
|
|
819
|
-
let rows;
|
|
820
|
-
try {
|
|
821
|
-
rows = JSON.parse(text);
|
|
822
|
-
}
|
|
823
|
-
catch {
|
|
824
|
-
return [];
|
|
825
|
-
}
|
|
826
|
-
const list = Array.isArray(rows) ? rows : [rows];
|
|
827
|
-
return list
|
|
828
|
-
.map((x) => ({
|
|
829
|
-
pid: Number(x?.ProcessId) || 0,
|
|
830
|
-
name: String(x?.Name ?? ""),
|
|
831
|
-
cmdline: String(x?.CommandLine ?? ""),
|
|
832
|
-
}))
|
|
833
|
-
.filter((x) => x.pid > 0);
|
|
834
|
-
}
|
|
835
|
-
const r = spawnSync("ps", ["-eo", "pid=,ppid=,args="], { encoding: "utf8", timeout: 4000 });
|
|
836
|
-
if (r.status !== 0)
|
|
837
|
-
return [];
|
|
838
|
-
const out = [];
|
|
839
|
-
for (const line of String(r.stdout ?? "").split("\n")) {
|
|
840
|
-
const m = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
|
|
841
|
-
if (!m || Number(m[2]) !== ppid)
|
|
842
|
-
continue;
|
|
843
|
-
const cmdline = m[3].trim();
|
|
844
|
-
out.push({ pid: Number(m[1]), name: path.basename(cmdline.split(/\s+/)[0] ?? ""), cmdline });
|
|
845
|
-
}
|
|
846
|
-
return out;
|
|
847
|
-
}
|
|
848
|
-
/** The OS pid behind a playwright-launched browser.
|
|
849
|
-
*
|
|
850
|
-
* playwright-core has NO `Browser.process()` — that accessor belongs to
|
|
851
|
-
* ElectronApplication and BrowserServer. Measured on the desktop host with a
|
|
852
|
-
* real 1.63 session: `typeof browser.process === "undefined"`, so the pid
|
|
853
|
-
* stayed 0, the orphan ledger recorded nothing, and close printed 已确认关闭
|
|
854
|
-
* over a browser process it had never checked.
|
|
855
|
-
*
|
|
856
|
-
* The only exact anchor available is that the browser is a DIRECT CHILD of
|
|
857
|
-
* this process, started from the executable WE resolved. That matters: this
|
|
858
|
-
* machine carries a dozen unrelated msedge trees (the user's own windows,
|
|
859
|
-
* msedgewebview2 under SearchHost.exe and under a vendor utility), so matching
|
|
860
|
-
* by process name would be a loaded gun pointed at them.
|
|
861
|
-
*
|
|
862
|
-
* 0 = not found, and every caller treats 0 as "no verification" — never as
|
|
863
|
-
* "verified gone".
|
|
864
|
-
*/
|
|
865
|
-
export function launchedBrowserPid(executablePath, opts = {}) {
|
|
866
|
-
const exe = String(executablePath ?? "").trim();
|
|
867
|
-
const base = path.basename(exe).toLowerCase();
|
|
868
|
-
if (!base)
|
|
869
|
-
return 0;
|
|
870
|
-
let rows;
|
|
871
|
-
try {
|
|
872
|
-
rows = opts.rows ?? childProcRows(opts.ppid ?? process.pid);
|
|
873
|
-
}
|
|
874
|
-
catch {
|
|
875
|
-
return 0;
|
|
876
|
-
}
|
|
877
|
-
const mine = (rows ?? []).filter((r) => Number.isFinite(Number(r?.pid)) && Number(r.pid) > 0);
|
|
878
|
-
const byPath = mine.filter((r) => String(r.cmdline ?? "").toLowerCase().includes(exe.toLowerCase()));
|
|
879
|
-
if (byPath.length === 1)
|
|
880
|
-
return Number(byPath[0].pid);
|
|
881
|
-
// More than one of OUR children from the same executable = an older session
|
|
882
|
-
// that has not been reaped yet. pids climb, so the highest is this launch.
|
|
883
|
-
if (byPath.length > 1)
|
|
884
|
-
return Math.max(...byPath.map((r) => Number(r.pid)));
|
|
885
|
-
// Name fallback, and only for rows where the OS gave us NO command line at
|
|
886
|
-
// all. A row that carries a command line for some OTHER executable is
|
|
887
|
-
// evidence against, not a missing clue — adopting it would mean signalling a
|
|
888
|
-
// browser we never launched.
|
|
889
|
-
const byName = mine.filter((r) => String(r.cmdline ?? "").trim() === "" && String(r.name ?? "").toLowerCase() === base);
|
|
890
|
-
return byName.length === 1 ? Number(byName[0].pid) : 0;
|
|
891
|
-
}
|
|
892
|
-
/** Consent is per BROWSER SESSION, not per call: one dialog when the lead
|
|
893
|
-
* decides to script the page, then the round stops paying for it. */
|
|
894
|
-
export function needsEvalConsent(policy, alreadyApproved) {
|
|
895
|
-
return policy !== "off" && !alreadyApproved;
|
|
896
|
-
}
|
|
897
|
-
/** A function-shaped source has to be INVOKED. Every browser MCP documents
|
|
898
|
-
* `function: "() => …"`, but evaluate() treats a STRING as an expression: a
|
|
899
|
-
* function source evaluates to a function object, which is not serializable,
|
|
900
|
-
* so the call resolves to undefined and the agent reads "null" and goes
|
|
901
|
-
* looking for a bug that is in our call rather than in the page (measured
|
|
902
|
-
* live 2026-09-21 — six rounds lost this way). An expression the model
|
|
903
|
-
* already wrapped, or a bare `document.title`, passes through untouched. */
|
|
904
|
-
export function evalExpression(src) {
|
|
905
|
-
const t = src.trim();
|
|
906
|
-
if (!t)
|
|
907
|
-
return t;
|
|
908
|
-
const fnLike = /^(async\s+)?function\b/.test(t) ||
|
|
909
|
-
/^(async\s+)?\(\s*[^()]*?\s*\)\s*=>/.test(t) ||
|
|
910
|
-
/^(async\s+)?[A-Za-z_$][\w$]*\s*=>/.test(t);
|
|
911
|
-
return fnLike ? `(${t})()` : t;
|
|
912
|
-
}
|
|
913
|
-
/** `null` is not a diagnosis. The render names the type, so "the page has no
|
|
914
|
-
* such links" and "your function returned nothing" and "it was not called"
|
|
915
|
-
* stop looking like the same answer. */
|
|
916
|
-
export function renderEvalResult(value) {
|
|
917
|
-
if (value === undefined) {
|
|
918
|
-
return ("执行结果:undefined —— 函数没有 return 值(或返回了不可序列化的对象,比如 DOM 节点本身)。" +
|
|
919
|
-
"要拿数据就返回字符串或数组,例:() => [...document.querySelectorAll('a')].map(a => a.href)");
|
|
920
|
-
}
|
|
921
|
-
if (value === null)
|
|
922
|
-
return "执行结果:null(页面确实返回了 null —— 这是成功执行,不是失败)";
|
|
923
|
-
const kind = Array.isArray(value) ? `array(${value.length})` : typeof value;
|
|
924
|
-
const json = JSON.stringify(value) ?? String(value);
|
|
925
|
-
return `执行结果(${kind}):${json}`;
|
|
926
|
-
}
|
|
927
|
-
/** Host only — the consent line in the dialog and in the trajectory never
|
|
928
|
-
* carries a query string (a search URL can hold the very token we guard). */
|
|
929
|
-
export function hostOnly(url) {
|
|
930
|
-
try {
|
|
931
|
-
return new URL(url).host;
|
|
932
|
-
}
|
|
933
|
-
catch {
|
|
934
|
-
return url.slice(0, 60);
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
const CLICK_EFFECT_BUDGET_MS = 900;
|
|
938
|
-
const CLICK_POLL_MS = 150;
|
|
939
|
-
const CLICK_SETTLE_MS = 5000;
|
|
940
|
-
/** Runs in the page. Kept free of TS annotations and outer-scope references
|
|
941
|
-
* because playwright serializes it. */
|
|
942
|
-
const clickProbeFn = (el) => {
|
|
943
|
-
const attrs = {};
|
|
944
|
-
for (const a of ["aria-expanded", "aria-checked", "aria-selected", "aria-pressed", "disabled"]) {
|
|
945
|
-
const v = el.getAttribute ? el.getAttribute(a) : null;
|
|
946
|
-
if (v !== null && v !== undefined)
|
|
947
|
-
attrs[a] = String(v);
|
|
948
|
-
}
|
|
949
|
-
if (typeof el.value === "string")
|
|
950
|
-
attrs.value = el.value;
|
|
951
|
-
return {
|
|
952
|
-
attrs,
|
|
953
|
-
url: String(location.href || ""),
|
|
954
|
-
ready: String(document.readyState || ""),
|
|
955
|
-
nodes: document.getElementsByTagName("*").length,
|
|
956
|
-
};
|
|
957
|
-
};
|
|
958
|
-
async function probeClickTarget(loc) {
|
|
959
|
-
if (typeof loc.evaluate !== "function")
|
|
960
|
-
return null;
|
|
961
|
-
try {
|
|
962
|
-
const raw = (await loc.evaluate(clickProbeFn));
|
|
963
|
-
if (!raw || typeof raw !== "object")
|
|
964
|
-
return null;
|
|
965
|
-
return {
|
|
966
|
-
attrs: (raw.attrs ?? {}),
|
|
967
|
-
url: String(raw.url ?? ""),
|
|
968
|
-
ready: String(raw.ready ?? ""),
|
|
969
|
-
nodes: Number(raw.nodes ?? 0),
|
|
970
|
-
};
|
|
971
|
-
}
|
|
972
|
-
catch {
|
|
973
|
-
return null; // a detached element is an answer, not a crash
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
/** Bounded settle. `open` deliberately returns at domcontentloaded — reading a
|
|
977
|
-
* page must not wait for analytics — so the wait belongs to the action that
|
|
978
|
-
* needs a handler attached, not to every navigation. */
|
|
979
|
-
async function settlePage(page, timeoutMs = CLICK_SETTLE_MS) {
|
|
980
|
-
const waiting = typeof page.waitForLoadState === "function" ? page.waitForLoadState("load", { timeout: timeoutMs }) : null;
|
|
981
|
-
if (waiting) {
|
|
982
|
-
await Promise.race([
|
|
983
|
-
Promise.resolve(waiting).catch(() => undefined),
|
|
984
|
-
new Promise((r) => setTimeout(r, timeoutMs)),
|
|
985
|
-
]);
|
|
986
|
-
}
|
|
987
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
988
|
-
}
|
|
989
|
-
/** What differs between two probes, in the order a reader cares about. */
|
|
990
|
-
export function describeClickChange(before, after) {
|
|
991
|
-
const out = [];
|
|
992
|
-
const attrsBefore = before.attrs ?? {};
|
|
993
|
-
const attrsAfter = after.attrs ?? {};
|
|
994
|
-
for (const k of Object.keys(attrsBefore)) {
|
|
995
|
-
if (attrsAfter[k] !== attrsBefore[k])
|
|
996
|
-
out.push(`${k}: ${attrsBefore[k]} → ${attrsAfter[k] ?? "(消失)"}`);
|
|
997
|
-
}
|
|
998
|
-
for (const k of Object.keys(attrsAfter)) {
|
|
999
|
-
if (!(k in attrsBefore))
|
|
1000
|
-
out.push(`${k}: → ${attrsAfter[k]}`);
|
|
1001
|
-
}
|
|
1002
|
-
if (before.url && after.url && before.url !== after.url)
|
|
1003
|
-
out.push(`已跳转 → ${after.url.slice(0, 120)}`);
|
|
1004
|
-
if (before.nodes && after.nodes !== before.nodes)
|
|
1005
|
-
out.push(`DOM 节点 ${before.nodes} → ${after.nodes}`);
|
|
1006
|
-
return out;
|
|
1007
|
-
}
|
|
1008
|
-
/** Poll until something changed or the budget runs out. A navigating click can
|
|
1009
|
-
* detach the element, so the URL is checked independently of the probe. */
|
|
1010
|
-
async function observeClickEffect(loc, page, before, budgetMs = CLICK_EFFECT_BUDGET_MS) {
|
|
1011
|
-
const deadline = Date.now() + Math.max(0, budgetMs);
|
|
1012
|
-
for (;;) {
|
|
1013
|
-
const after = await probeClickTarget(loc);
|
|
1014
|
-
if (after) {
|
|
1015
|
-
const changed = describeClickChange(before, after);
|
|
1016
|
-
if (changed.length)
|
|
1017
|
-
return { changed, after };
|
|
1018
|
-
}
|
|
1019
|
-
else {
|
|
1020
|
-
const urlNow = typeof page.url === "function" ? String(page.url() ?? "") : "";
|
|
1021
|
-
if (urlNow && before.url && urlNow !== before.url)
|
|
1022
|
-
return { changed: [`已跳转 → ${urlNow.slice(0, 120)}`], after: null };
|
|
1023
|
-
}
|
|
1024
|
-
if (Date.now() >= deadline)
|
|
1025
|
-
return { changed: [], after };
|
|
1026
|
-
await new Promise((r) => setTimeout(r, CLICK_POLL_MS));
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
/** The reply. Pure, so the wording is pinnable without a browser: a click that
|
|
1030
|
-
* observably did something says what changed; one that did not says so and
|
|
1031
|
-
* names the next move instead of reporting success. */
|
|
1032
|
-
export function clickVerdict(target, before, changed, opts) {
|
|
1033
|
-
const head = `已点击 ${target}`;
|
|
1034
|
-
const dialogNote = opts.dialog ? ";有未处理对话框——handle_dialog(同轮观察,勿另起动作)" : "";
|
|
1035
|
-
if (!before)
|
|
1036
|
-
return head + dialogNote; // nothing could be probed: claim no more than we know
|
|
1037
|
-
if (changed.length) {
|
|
1038
|
-
const retryNote = opts.retried
|
|
1039
|
-
? `(第一次点击落在页面还没就绪时 readyState=${before.ready || "?"},未观测到变化;等加载完重试才生效)`
|
|
1040
|
-
: "";
|
|
1041
|
-
return `${head} · ${changed.join(" · ")}${retryNote}${dialogNote}`;
|
|
1042
|
-
}
|
|
1043
|
-
const watched = Object.keys(before.attrs).length
|
|
1044
|
-
? Object.entries(before.attrs).map(([k, v]) => `${k}=${v}`).join(" ")
|
|
1045
|
-
: "无可观测状态属性";
|
|
1046
|
-
const facts = `${watched} · URL 未变 · DOM 节点 ${before.nodes} 未变 · readyState=${before.ready || "?"}`;
|
|
1047
|
-
// Three different reasons produce the same "nothing changed", and the advice
|
|
1048
|
-
// that fits one of them is a dead end for the others. Measured live: a page
|
|
1049
|
-
// at readyState=complete whose framework bundles the governance gate had
|
|
1050
|
-
// trimmed was told to "wait for the text" — wait_for then threw a strict-mode
|
|
1051
|
-
// violation, so the recommended way out led nowhere.
|
|
1052
|
-
const why = opts.blocked && opts.blocked.count > 0
|
|
1053
|
-
? `最可能的原因不是"还没加载完",而是**这个页面的脚本被治理闸门拦掉了**:本次有 ${opts.blocked.count} 个子资源被拦截` +
|
|
1054
|
-
`${opts.blocked.hosts.length ? `(${opts.blocked.hosts.join(", ")})` : ""},而它们的可执行资源不属于你导航过的那个站。` +
|
|
1055
|
-
`出路:请操作者把这些主机加进 TM_WEBFETCH_ALLOWED_DOMAINS(或先在确认窗里批准该站点)后重新 open;` +
|
|
1056
|
-
`也可以直接 TM_BROWSER_SUBRESOURCE=off 之外逐案判断——总之现在这个按钮上没有 handler。`
|
|
1057
|
-
: before.ready === "complete"
|
|
1058
|
-
? `页面已经加载完成(readyState=complete),所以这不是加载时机问题:这个元素本身不响应这种点击(常见于 <a> 需要 navigate、事件绑在父节点、或被上层遮罩)。` +
|
|
1059
|
-
`下一步:重新 take_snapshot 看清结构再决定,或改用 navigate / evaluate_script 直接取你要的东西。`
|
|
1060
|
-
: `最常见的原因是页面脚本还没跑完(readyState=${before.ready || "?"}),而 playwright 的可见/稳定/可点检查在这种情况下全部通过。` +
|
|
1061
|
-
`下一步:wait_for 你要点的文字,或重新 take_snapshot 再点一次。`;
|
|
1062
|
-
return `${head},但页面没有任何可观测变化(${facts})。点击送达了,可是没有 handler 响应。${why}不要把这次点击当成成功。${dialogNote}`;
|
|
1063
|
-
}
|
|
1064
|
-
class PipeCdp {
|
|
1065
|
-
child;
|
|
1066
|
-
nextId = 1;
|
|
1067
|
-
pending = new Map();
|
|
1068
|
-
buf = Buffer.alloc(0);
|
|
1069
|
-
events = [];
|
|
1070
|
-
onEvent = (handler) => (this.eventHandler = handler);
|
|
1071
|
-
eventHandler = () => { };
|
|
1072
|
-
constructor(child) {
|
|
1073
|
-
this.child = child;
|
|
1074
|
-
const inp = child.stdio[4];
|
|
1075
|
-
inp?.on("data", (chunk) => {
|
|
1076
|
-
this.buf = Buffer.concat([this.buf, chunk]);
|
|
1077
|
-
for (;;) {
|
|
1078
|
-
const i = this.buf.indexOf(0);
|
|
1079
|
-
if (i === -1)
|
|
1080
|
-
break;
|
|
1081
|
-
const line = this.buf.subarray(0, i).toString("utf8");
|
|
1082
|
-
this.buf = this.buf.subarray(i + 1);
|
|
1083
|
-
try {
|
|
1084
|
-
const m = JSON.parse(line);
|
|
1085
|
-
if (m.id && this.pending.has(m.id)) {
|
|
1086
|
-
this.pending.get(m.id)(m);
|
|
1087
|
-
this.pending.delete(m.id);
|
|
1088
|
-
}
|
|
1089
|
-
else if (m.method) {
|
|
1090
|
-
this.events.push(m);
|
|
1091
|
-
this.eventHandler(m);
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
catch {
|
|
1095
|
-
/* partial frame */
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
});
|
|
1099
|
-
}
|
|
1100
|
-
call(method, params = {}, sessionId, timeoutMs = 20_000) {
|
|
1101
|
-
const id = this.nextId++;
|
|
1102
|
-
const pipe = this.child.stdio[3];
|
|
1103
|
-
return new Promise((res, rej) => {
|
|
1104
|
-
const timer = setTimeout(() => {
|
|
1105
|
-
if (this.pending.has(id)) {
|
|
1106
|
-
this.pending.delete(id);
|
|
1107
|
-
rej(new Error(`CDP ${method} 超时(${timeoutMs}ms)`));
|
|
1108
|
-
}
|
|
1109
|
-
}, timeoutMs);
|
|
1110
|
-
this.pending.set(id, (m) => {
|
|
1111
|
-
clearTimeout(timer);
|
|
1112
|
-
m.error ? rej(new Error(`CDP ${method} -> ${JSON.stringify(m.error)}`)) : res(m.result ?? {});
|
|
1113
|
-
});
|
|
1114
|
-
pipe?.write(Buffer.from(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }) + "\0"));
|
|
1115
|
-
});
|
|
1116
|
-
}
|
|
1117
|
-
async waitEvent(method, sessionId, timeoutMs = 15_000) {
|
|
1118
|
-
const t0 = Date.now();
|
|
1119
|
-
for (;;) {
|
|
1120
|
-
const i = this.events.findIndex((e) => e.method === method && (!sessionId || e.sessionId === sessionId));
|
|
1121
|
-
if (i >= 0)
|
|
1122
|
-
return this.events.splice(i, 1)[0];
|
|
1123
|
-
if (Date.now() - t0 > timeoutMs)
|
|
1124
|
-
throw new Error(`等待 CDP 事件 ${method} 超时`);
|
|
1125
|
-
await new Promise((r) => setTimeout(r, 80));
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
/** playwright-core 1.63 targets node >= 20; on anything older we refuse the
|
|
1130
|
-
* engine up-front and stay on the zero-dependency CDP pipe (the node>=18
|
|
1131
|
-
* package floor keeps the plugin itself loadable — only the playwright
|
|
1132
|
-
* PRIMARY is gated here, cdp-legacy carries node 18/19 hosts). */
|
|
1133
|
-
export const PLAYWRIGHT_MIN_NODE_MAJOR = 20;
|
|
1134
|
-
/** "v22.3.1" / "20" -> major number; unparseable -> 0 (fail toward the
|
|
1135
|
-
* always-available cdp-legacy engine). */
|
|
1136
|
-
export function nodeMajorOf(version) {
|
|
1137
|
-
const m = /^v?(\d+)/.exec(String(version ?? "").trim());
|
|
1138
|
-
return m ? Number(m[1]) : 0;
|
|
1139
|
-
}
|
|
1140
|
-
/** The REAL loader — a VARIABLE specifier so tsc never tries to resolve
|
|
1141
|
-
* "playwright-core" at build time (types ship inside the package, which may
|
|
1142
|
-
* be absent from node_modules). Under ESM, import(variable) yields the
|
|
1143
|
-
* module namespace typed as any. */
|
|
1144
|
-
export async function defaultImportPlaywright() {
|
|
1145
|
-
const specifier = "playwright-core";
|
|
1146
|
-
return await import(specifier);
|
|
1147
|
-
}
|
|
1148
|
-
/** Pure engine-selection seam (exported for test-browser.mjs):
|
|
1149
|
-
* cdp-legacy preference → legacy WITHOUT ever importing; node < 20 →
|
|
1150
|
-
* legacy without importing; playwright preference → import, verify the
|
|
1151
|
-
* chromium.launch shape, degrade to legacy on ANY failure. */
|
|
1152
|
-
export async function selectBrowserEngine(opts) {
|
|
1153
|
-
const preference = opts.preference === "cdp-legacy" ? "cdp-legacy" : "playwright";
|
|
1154
|
-
if (preference === "cdp-legacy") {
|
|
1155
|
-
return { kind: "cdp-legacy", reason: "TM_BROWSER_ENGINE=cdp-legacy 偏好" };
|
|
1156
|
-
}
|
|
1157
|
-
if (opts.nodeMajor < PLAYWRIGHT_MIN_NODE_MAJOR) {
|
|
1158
|
-
return {
|
|
1159
|
-
kind: "cdp-legacy",
|
|
1160
|
-
reason: `node ${opts.nodeMajor || "?"} < ${PLAYWRIGHT_MIN_NODE_MAJOR} — playwright-core 不受支持,自动降级 cdp-legacy`,
|
|
1161
|
-
};
|
|
1162
|
-
}
|
|
1163
|
-
try {
|
|
1164
|
-
const mod = (await opts.importPlaywright());
|
|
1165
|
-
if (!mod || typeof mod.chromium?.launch !== "function") {
|
|
1166
|
-
throw new Error("playwright-core 模块形状异常(缺 chromium.launch)");
|
|
1167
|
-
}
|
|
1168
|
-
return { kind: "playwright", pw: mod };
|
|
1169
|
-
}
|
|
1170
|
-
catch (e) {
|
|
1171
|
-
return {
|
|
1172
|
-
kind: "cdp-legacy",
|
|
1173
|
-
reason: `playwright-core 加载失败:${e?.message ?? String(e)} — 自动降级 cdp-legacy(快照类动作不可用)`,
|
|
1174
|
-
};
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
/** ariaSnapshot yields plain YAML with NO addressable id, while the
|
|
1178
|
-
* chrome-devtools-mcp muscle memory this tool aligns to is uid-addressed.
|
|
1179
|
-
* The missing layer is built here: each take_snapshot re-indexes every YAML
|
|
1180
|
-
* node (`- <role> "<name>" …`), injects `[uid=eN]` inline, and remembers
|
|
1181
|
-
* (role, name, nth-of-kind); later actions resolve uid →
|
|
1182
|
-
* `page.getByRole(role, { name, exact: true }).nth(nth)`. Uids are
|
|
1183
|
-
* renumbered on every snapshot (one action, one observation); an
|
|
1184
|
-
* unknown/stale uid errors back to take_snapshot instead of falling back
|
|
1185
|
-
* to guessed locators. */
|
|
1186
|
-
export class SnapshotIndex {
|
|
1187
|
-
entries = new Map();
|
|
1188
|
-
next = 0;
|
|
1189
|
-
get size() {
|
|
1190
|
-
return this.entries.size;
|
|
1191
|
-
}
|
|
1192
|
-
/** Reset + parse + annotate the ariaSnapshot YAML in one pass. */
|
|
1193
|
-
annotate(yaml) {
|
|
1194
|
-
this.entries.clear();
|
|
1195
|
-
this.next = 0;
|
|
1196
|
-
const counters = new Map();
|
|
1197
|
-
const out = [];
|
|
1198
|
-
for (const line of String(yaml ?? "").split(/\r?\n/)) {
|
|
1199
|
-
// `- role`, `- role "name"`, `- role:`, `- role "name" [attrs]` …
|
|
1200
|
-
// the `text` pseudo-role carries no interactive semantics: skip it.
|
|
1201
|
-
const m = /^(\s*-\s+)([A-Za-z][\w-]*)((?:"[^"]*"|.)*?)$/.exec(line);
|
|
1202
|
-
if (!m || m[2] === "text") {
|
|
1203
|
-
out.push(line);
|
|
1204
|
-
continue;
|
|
1205
|
-
}
|
|
1206
|
-
const role = m[2];
|
|
1207
|
-
const rest = m[3];
|
|
1208
|
-
const uid = `e${++this.next}`;
|
|
1209
|
-
const rawName = /"((?:[^"\\]|\\.)*)"/.exec(rest)?.[1];
|
|
1210
|
-
const name = rawName === undefined ? undefined : unescapeAriaName(rawName);
|
|
1211
|
-
const key = `${role}\u0000${name ?? ""}`;
|
|
1212
|
-
const nth = counters.get(key) ?? 0;
|
|
1213
|
-
counters.set(key, nth + 1);
|
|
1214
|
-
this.entries.set(uid, { role, name, nth });
|
|
1215
|
-
// container lines (`- list:`) get the uid BEFORE the colon so the
|
|
1216
|
-
// YAML `key:` child-anchor semantics stay intact; names may contain
|
|
1217
|
-
// colons, hence the startsWith check instead of a split.
|
|
1218
|
-
out.push(rest.startsWith(":") ? `${m[1]}${role} [uid=${uid}]${rest}` : `${line} [uid=${uid}]`);
|
|
1219
|
-
}
|
|
1220
|
-
return out.join("\n");
|
|
1221
|
-
}
|
|
1222
|
-
get(uid) {
|
|
1223
|
-
return this.entries.get(String(uid ?? "").trim());
|
|
1224
|
-
}
|
|
1225
|
-
/** uid → playwright locator; null = unknown uid / role the engine cannot
|
|
1226
|
-
* address (the caller renders the take_snapshot-first guidance). */
|
|
1227
|
-
locatorFor(page, uid) {
|
|
1228
|
-
const entry = this.get(uid);
|
|
1229
|
-
if (!entry)
|
|
1230
|
-
return null;
|
|
1231
|
-
try {
|
|
1232
|
-
const base = entry.name !== undefined ? page.getByRole(entry.role, { name: entry.name, exact: true }) : page.getByRole(entry.role);
|
|
1233
|
-
return base.nth(entry.nth);
|
|
1234
|
-
}
|
|
1235
|
-
catch {
|
|
1236
|
-
return null;
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
/** ariaSnapshot escapes whitespace/quotes inside names (\s \n \t \" \\). */
|
|
1241
|
-
export function unescapeAriaName(raw) {
|
|
1242
|
-
return String(raw).replace(/\\(.)/g, (_, c) => (c === "s" ? " " : c === "n" ? "\n" : c === "t" ? "\t" : c));
|
|
1243
|
-
}
|
|
1244
|
-
// ---------- action surface (R2: chrome-devtools-mcp 18-verb alignment) --------
|
|
1245
|
-
/** The 18 canonical verbs, named byte-identical to chrome-devtools-mcp. */
|
|
1246
|
-
export const BROWSER_PLAYWRIGHT_ACTIONS = [
|
|
1247
|
-
"navigate_page",
|
|
1248
|
-
"take_snapshot",
|
|
1249
|
-
"click",
|
|
1250
|
-
"fill",
|
|
1251
|
-
"hover",
|
|
1252
|
-
"drag",
|
|
1253
|
-
"press_key",
|
|
1254
|
-
"select_page",
|
|
1255
|
-
"new_page",
|
|
1256
|
-
"close_page",
|
|
1257
|
-
"upload_file",
|
|
1258
|
-
"wait_for",
|
|
1259
|
-
"evaluate_script",
|
|
1260
|
-
"list_console_messages",
|
|
1261
|
-
"list_network_requests",
|
|
1262
|
-
"list_pages",
|
|
1263
|
-
"take_screenshot",
|
|
1264
|
-
"handle_dialog",
|
|
1265
|
-
];
|
|
1266
|
-
/** Legacy compat verbs that ride on top of the 18 (both engines). */
|
|
1267
|
-
export const BROWSER_COMPAT_ACTIONS = ["open", "navigate", "read", "screenshot", "close"];
|
|
1268
|
-
/** Verbs of our own that act on the GATE rather than the DOM: no page is
|
|
1269
|
-
* involved, so both engines serve them and neither can drift. */
|
|
1270
|
-
export const BROWSER_GATE_ACTIONS = ["allow_host"];
|
|
1271
|
-
/** Every verb the execute layer accepts — the ONE table the refusal menu and
|
|
1272
|
-
* the args schema are both built from. The hand-written copies drifted the
|
|
1273
|
-
* day `new_page` landed (§6o/unknown-action menu), and a schema that omits a
|
|
1274
|
-
* verb is a capability no agent knows it has. */
|
|
1275
|
-
export const ALL_BROWSER_ACTIONS = [
|
|
1276
|
-
...BROWSER_PLAYWRIGHT_ACTIONS,
|
|
1277
|
-
...BROWSER_COMPAT_ACTIONS,
|
|
1278
|
-
...BROWSER_GATE_ACTIONS,
|
|
1279
|
-
];
|
|
1280
|
-
export const BROWSER_ACTION_MENU = ALL_BROWSER_ACTIONS.join(" | ");
|
|
1281
|
-
/** The verbs that must still WORK when a browser has zero tabs: `new_page` is
|
|
1282
|
-
* the remedy the error text points at, `list_pages` is how you find out you
|
|
1283
|
-
* are at zero, and `close_page` answers with its own "index 越界(0..-1)"
|
|
1284
|
-
* rather than this sentence. */
|
|
1285
|
-
const ZERO_TAB_SAFE = new Set(["new_page", "list_pages", "close_page"]);
|
|
1286
|
-
/** What the cdp-legacy fallback can still do (R2: degradation must keep the
|
|
1287
|
-
* §6o behavior; everything snapshot-shaped is playwright-only). */
|
|
1288
|
-
export const BROWSER_LEGACY_ACTIONS = [
|
|
1289
|
-
"open",
|
|
1290
|
-
"navigate",
|
|
1291
|
-
"navigate_page",
|
|
1292
|
-
"read",
|
|
1293
|
-
"screenshot",
|
|
1294
|
-
"take_screenshot",
|
|
1295
|
-
"list_pages",
|
|
1296
|
-
"evaluate_script",
|
|
1297
|
-
"close",
|
|
1298
|
-
];
|
|
1299
|
-
/** Default action/wait budget — the prompt's "3000 ms" discipline is the
|
|
1300
|
-
* ENGINE default, so agents that forget a timeout still fail fast. */
|
|
1301
|
-
const PW_DEFAULT_TIMEOUT_MS = 3000;
|
|
1302
|
-
const OBSERVE_BUFFER_CAP = 200;
|
|
1303
|
-
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";
|
|
1304
|
-
// The browser's OWN site-permission bubble ("…想要 访问此设备上的其他应用和服务",
|
|
1305
|
-
// 阻止/允许) is not our ctx.ask channel, so nothing in perm-ask or the approval
|
|
1306
|
-
// gate can bound it: measured on npmjs.com, an unanswered bubble held
|
|
1307
|
-
// `page.goto` past its 45 s timeout without even reaching domcontentloaded, and
|
|
1308
|
-
// a server-side session has nobody to click. Denying at launch is the safe
|
|
1309
|
-
// direction and the ONLY one this toolset takes — there is deliberately no
|
|
1310
|
-
// auto-allow counterpart (no `permissions:` in the context options either),
|
|
1311
|
-
// because a browser that grants a site device access on the plugin's say-so
|
|
1312
|
-
// would be the self-allowing the rest of this codebase refuses to do.
|
|
1313
|
-
export const BROWSER_DENY_PERMISSIONS_ARG = "--deny-permission-prompts";
|
|
1314
|
-
const SPAWN_TIMEOUT_MS = 15_000;
|
|
1315
|
-
const NAVIGATE_EVENT_TIMEOUT_MS = 15_000;
|
|
1316
|
-
export function callerOf(ctx) {
|
|
1317
|
-
const c = (ctx ?? {});
|
|
1318
|
-
return { owner: String(c.sessionID ?? "").trim(), agent: String(c.agent ?? "").trim() };
|
|
1319
|
-
}
|
|
1320
|
-
export function buildTmBrowserTool(deps) {
|
|
1321
|
-
const { pipelines } = deps;
|
|
1322
|
-
const env = deps.env ?? process.env;
|
|
1323
|
-
const notify = typeof deps.notify === "function" ? deps.notify : undefined;
|
|
1324
|
-
const tool = "tm_browser";
|
|
1325
|
-
const traj = (e) => pipelines.store.appendTrajectory({ tool, ...e });
|
|
1326
|
-
// The orphan ledger lives beside the run store (per workspace since the
|
|
1327
|
-
// tmpdir sharding), so a crash in one project can never reap another's
|
|
1328
|
-
// browser — and a test run, which gets its own store dir, never reaches a
|
|
1329
|
-
// real one either.
|
|
1330
|
-
const ledgerFile = (() => {
|
|
1331
|
-
const root = pipelines.store?.blackboardRoot;
|
|
1332
|
-
// No store root (a test double, or a host that never gave us one) means no
|
|
1333
|
-
// ledger — and then nothing is recorded and nothing is reaped. It must not
|
|
1334
|
-
// throw on the way to opening a browser.
|
|
1335
|
-
return typeof root === "string" && root ? browserLedgerFile(root) : "";
|
|
1336
|
-
})();
|
|
1337
|
-
const recordBrowser = (pid, engine, exe) => {
|
|
1338
|
-
if (!ledgerFile || !Number.isFinite(pid) || pid <= 0)
|
|
1339
|
-
return;
|
|
1340
|
-
appendBrowserLedger(ledgerFile, { pid, ownerPid: process.pid, engine, at: Date.now(), exe });
|
|
1341
|
-
};
|
|
1342
|
-
if (ledgerFile && String(env.TM_BROWSER_REAP ?? "on").trim().toLowerCase() !== "off") {
|
|
1343
|
-
const r = reapOrphanBrowsers(ledgerFile);
|
|
1344
|
-
if (r.reaped.length)
|
|
1345
|
-
traj({ step_id: "browser", event: "orphans_reaped", count: r.reaped.length, pids: r.reaped.join(",") });
|
|
1346
|
-
}
|
|
1347
|
-
/** Live browser leases by id — ONE browser per caller (see BrowserLease). */
|
|
1348
|
-
const leases = new Map();
|
|
1349
|
-
let leaseSeq = 0;
|
|
1350
|
-
const liveLeases = () => [...leases.values()];
|
|
1351
|
-
const ownedBy = (owner) => liveLeases().filter((l) => l.owner === owner);
|
|
1352
|
-
const leaseTable = () => liveLeases()
|
|
1353
|
-
.map((l) => `${l.id} · ${l.agent || "未知角色"} · ${String(l.session.currentUrl || "").slice(0, 80) || "空白页"}`)
|
|
1354
|
-
.join("\n ");
|
|
1355
|
-
/** Idle-reaper timers, one per lease: a forgotten window belongs to its
|
|
1356
|
-
* owner, so B's activity must not keep A's browser alive (or reap it). */
|
|
1357
|
-
const idleTimers = new Map();
|
|
1358
|
-
function clearIdle(id) {
|
|
1359
|
-
const t = idleTimers.get(id);
|
|
1360
|
-
if (t)
|
|
1361
|
-
clearTimeout(t);
|
|
1362
|
-
idleTimers.delete(id);
|
|
1363
|
-
}
|
|
1364
|
-
/** Drop the lease whose session died on its own (a cdp-legacy child exiting,
|
|
1365
|
-
* a browser the user closed) so a corpse is never handed back out. */
|
|
1366
|
-
const dropSession = (sess) => {
|
|
1367
|
-
for (const [id, l] of leases) {
|
|
1368
|
-
if (l.session === sess) {
|
|
1369
|
-
leases.delete(id);
|
|
1370
|
-
clearIdle(id);
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
};
|
|
1374
|
-
// m2 (fix round): SAME seed policy as tm_webfetch / tm_search — the
|
|
1375
|
-
// built-in default list gains the T4 engine home hosts (api.stackexchange
|
|
1376
|
-
// .com, hn.algolia.com) so jump-reading a search hit stops re-popping the
|
|
1377
|
-
// dialog; a narrowed TM_WEBFETCH_ALLOWED_DOMAINS is never widened.
|
|
1378
|
-
const allowlist = seedWebfetchDomains(deps.cfg.webfetchAllowedDomains ?? ["*"]);
|
|
1379
|
-
/** Hosts approved through the OFFICIAL dialog, keyed by the CALLER's session.
|
|
1380
|
-
* It used to be one process-wide set, which meant a host the lead approved
|
|
1381
|
-
* became a silent pass for the researcher's browser too — consent given to
|
|
1382
|
-
* one agent is not a pass for another. The network layer (both engines)
|
|
1383
|
-
* consults the caller's set in addition to the static allowlist. */
|
|
1384
|
-
const approvedBy = new Map();
|
|
1385
|
-
const approvedFor = (owner) => {
|
|
1386
|
-
let s = approvedBy.get(owner);
|
|
1387
|
-
if (!s) {
|
|
1388
|
-
s = new Set();
|
|
1389
|
-
approvedBy.set(owner, s);
|
|
1390
|
-
}
|
|
1391
|
-
return s;
|
|
1392
|
-
};
|
|
1393
|
-
/** Which browser an action may drive.
|
|
1394
|
-
*
|
|
1395
|
-
* The id is REQUIRED as soon as it could mean more than one thing: with a
|
|
1396
|
-
* single live browser that belongs to the caller, omitting it is unambiguous
|
|
1397
|
-
* and allowed (and every reply echoes the id back); the moment a second
|
|
1398
|
-
* agent opens one, every call must name its own. An id belonging to somebody
|
|
1399
|
-
* else is refused WITH the owner named, because `b1` is guessable and the
|
|
1400
|
-
* whole point is that guessing must not work. */
|
|
1401
|
-
function resolveLease(args, caller) {
|
|
1402
|
-
const want = String(args.id ?? "").trim();
|
|
1403
|
-
const all = liveLeases();
|
|
1404
|
-
const mine = ownedBy(caller.owner);
|
|
1405
|
-
if (want) {
|
|
1406
|
-
const hit = all.find((l) => l.id === want);
|
|
1407
|
-
if (!hit) {
|
|
1408
|
-
return {
|
|
1409
|
-
error: all.length
|
|
1410
|
-
? `没有 id 为 "${want}" 的浏览器。活着的:\n ${leaseTable()}`
|
|
1411
|
-
: `没有 id 为 "${want}" 的浏览器——现在一个都没开,先用 action:"open"。`,
|
|
1412
|
-
};
|
|
1413
|
-
}
|
|
1414
|
-
if (hit.owner !== caller.owner) {
|
|
1415
|
-
return {
|
|
1416
|
-
error: `${hit.id} 是 ${hit.agent || "另一个会话"} 的浏览器,不是你的——两个 agent 共用一个窗口会互相踩当前标签和 uid 编号,` +
|
|
1417
|
-
`而你看到的每一次"成功"都可能是对方的页面。` +
|
|
1418
|
-
(mine.length
|
|
1419
|
-
? `你自己的是 ${mine.map((l) => l.id).join(" / ")}。`
|
|
1420
|
-
: `你还没有浏览器:用 action:"open" 开一个,它会给你自己的 id。`),
|
|
1421
|
-
};
|
|
1422
|
-
}
|
|
1423
|
-
hit.lastActivity = Date.now();
|
|
1424
|
-
return { lease: hit };
|
|
1425
|
-
}
|
|
1426
|
-
if (all.length === 1 && mine.length === 1) {
|
|
1427
|
-
mine[0].lastActivity = Date.now();
|
|
1428
|
-
return { lease: mine[0] };
|
|
1429
|
-
}
|
|
1430
|
-
if (!mine.length) {
|
|
1431
|
-
return {
|
|
1432
|
-
error: all.length
|
|
1433
|
-
? `你还没有浏览器(活着的:\n ${leaseTable()})——先用 action:"open" 开一个,之后带上它给你的 id。`
|
|
1434
|
-
: '没有打开的浏览器会话——先用 action:"open"',
|
|
1435
|
-
};
|
|
1436
|
-
}
|
|
1437
|
-
return {
|
|
1438
|
-
error: `现在有 ${all.length} 个浏览器在跑,动作必须写明 id(你自己的:${mine.map((l) => l.id).join(" / ")})——` +
|
|
1439
|
-
`省略 id 只在"全场只有一个、且是你的"时才安全。\n ${leaseTable()}`,
|
|
1440
|
-
};
|
|
1441
|
-
}
|
|
1442
|
-
const snapshotBudget = Math.max(10, Number(deps.cfg?.browserSnapshotMaxTokens) || 1200);
|
|
1443
|
-
const subPolicy = deps.cfg?.browserSubresource ?? "same-site";
|
|
1444
|
-
const evalAskPolicy = deps.cfg?.browserAskEval ?? "on";
|
|
1445
|
-
const discover = deps.findExecutable ?? findBrowserExecutable;
|
|
1446
|
-
const imageMaxBytes = Math.max(1000, Number(deps.cfg?.browserImageMaxBytes) || 400_000);
|
|
1447
|
-
const idleCloseMs = Number(deps.cfg?.browserIdleCloseMs ?? 180_000);
|
|
1448
|
-
// ---------- engine selection (cached per plugin instance) ----------
|
|
1449
|
-
let selPromise = null;
|
|
1450
|
-
let lastSel = null;
|
|
1451
|
-
const preference = String(deps.cfg?.browserEngine ?? "playwright");
|
|
1452
|
-
const selection = () => {
|
|
1453
|
-
if (!selPromise) {
|
|
1454
|
-
selPromise = selectBrowserEngine({
|
|
1455
|
-
preference,
|
|
1456
|
-
nodeMajor: deps.nodeMajor ?? nodeMajorOf(process.versions.node),
|
|
1457
|
-
importPlaywright: deps.importPlaywright ?? defaultImportPlaywright,
|
|
1458
|
-
}).then((sel) => {
|
|
1459
|
-
lastSel = sel;
|
|
1460
|
-
traj({ step_id: "browser", event: "engine", kind: sel.kind, reason: (sel.reason ?? "").slice(0, 200) });
|
|
1461
|
-
return sel;
|
|
1462
|
-
});
|
|
1463
|
-
}
|
|
1464
|
-
return selPromise;
|
|
1465
|
-
};
|
|
1466
|
-
/** Observe-payload cap shared by snapshot / console / network / eval. */
|
|
1467
|
-
function capTokens(text, note) {
|
|
1468
|
-
if (estimateTokens(text) <= snapshotBudget)
|
|
1469
|
-
return text;
|
|
1470
|
-
const lines = text.split("\n");
|
|
1471
|
-
let acc = 0;
|
|
1472
|
-
let i = 0;
|
|
1473
|
-
for (; i < lines.length; i++) {
|
|
1474
|
-
const cost = estimateTokens(lines[i]) + 1;
|
|
1475
|
-
if (acc + cost > snapshotBudget)
|
|
1476
|
-
break;
|
|
1477
|
-
acc += cost;
|
|
1478
|
-
}
|
|
1479
|
-
return `${lines.slice(0, i).join("\n")}\n${note}`;
|
|
1480
|
-
}
|
|
1481
|
-
function saveShotPng(bytes, stepId) {
|
|
1482
|
-
const png = Buffer.from(bytes);
|
|
1483
|
-
const dir = path.join(pipelines.store.stepsRoot(), stepId);
|
|
1484
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1485
|
-
const file = path.join(dir, "screenshot.png");
|
|
1486
|
-
fs.writeFileSync(file, png);
|
|
1487
|
-
traj({ step_id: stepId, event: "result", bytes: png.length });
|
|
1488
|
-
return { file, png };
|
|
1489
|
-
}
|
|
1490
|
-
/** LLM args are booleans most of the time, but `"false"` / `"0"` strings
|
|
1491
|
-
* are common — a bare `Boolean()` read both as TRUE, which is exactly how
|
|
1492
|
-
* a model used to force tm_browser into headless (and get anti-bot
|
|
1493
|
-
* blocked) on a desktop that defaults to headful. */
|
|
1494
|
-
function isTrueArg(raw) {
|
|
1495
|
-
if (raw === true)
|
|
1496
|
-
return true;
|
|
1497
|
-
if (typeof raw === "number")
|
|
1498
|
-
return raw === 1;
|
|
1499
|
-
if (typeof raw === "string")
|
|
1500
|
-
return /^(1|true|yes|on)$/i.test(raw.trim());
|
|
1501
|
-
return false;
|
|
1502
|
-
}
|
|
1503
|
-
/** Screenshot line + the OPT-IN pixels.
|
|
1504
|
-
* The PNG always lands in the run store (it is the evidence artifact);
|
|
1505
|
-
* what rides back to the MODEL is a quality-70 JPEG — a real page with
|
|
1506
|
-
* images loaded is ~1.5 MB as PNG, which no token budget should ever
|
|
1507
|
-
* inline, and ~150-300 KB as JPEG. Pixels stay opt-in so a UI sweep
|
|
1508
|
-
* cannot spend context the agent never asked for. */
|
|
1509
|
-
async function shotOutput(sess, file, png, args, captureJpeg) {
|
|
1510
|
-
const head = `截图已保存(PNG ${png.length} bytes):${file}`;
|
|
1511
|
-
if (!isTrueArg(args.image)) {
|
|
1512
|
-
return `${head}\n(上下文只携带路径,不携带像素——需要真正看到画面时带 image:true 再截一次。)`;
|
|
1513
|
-
}
|
|
1514
|
-
let img = null;
|
|
1515
|
-
if (captureJpeg) {
|
|
1516
|
-
try {
|
|
1517
|
-
img = await captureJpeg();
|
|
1518
|
-
}
|
|
1519
|
-
catch {
|
|
1520
|
-
img = null;
|
|
1521
|
-
}
|
|
1522
|
-
}
|
|
1523
|
-
let kind = "jpeg";
|
|
1524
|
-
if (!img) {
|
|
1525
|
-
img = png;
|
|
1526
|
-
kind = "png";
|
|
1527
|
-
}
|
|
1528
|
-
if (img.length > imageMaxBytes) {
|
|
1529
|
-
return `${head}\n(未附带像素:${kind.toUpperCase()} ${img.length} bytes 超过 TM_BROWSER_IMAGE_MAX_BYTES=${imageMaxBytes}。改用视口截图(去掉 fullPage)或提高该上限。)`;
|
|
1530
|
-
}
|
|
1531
|
-
sess.attachments.push({
|
|
1532
|
-
type: "file",
|
|
1533
|
-
mime: `image/${kind}`,
|
|
1534
|
-
url: `data:image/${kind};base64,${img.toString("base64")}`,
|
|
1535
|
-
filename: path.basename(file).replace(/\.png$/, `.${kind === "jpeg" ? "jpg" : "png"}`),
|
|
1536
|
-
});
|
|
1537
|
-
return `${head}\n(像素以 ${kind.toUpperCase()} ${img.length} bytes 随本条结果附带。)`;
|
|
1538
|
-
}
|
|
1539
|
-
/** One line telling the agent that the page is NOT actually empty — the
|
|
1540
|
-
* subresource gate dropped N requests. Without this the model reads a
|
|
1541
|
-
* blank-looking page and reports "该网站没有图片".
|
|
1542
|
-
*
|
|
1543
|
-
* `thin` means the page ALSO came back with nothing addressable, which
|
|
1544
|
-
* changes the sentence from a footnote into the diagnosis: on baike the
|
|
1545
|
-
* blocked request was the site's own bundle, so 0 nodes and "no content"
|
|
1546
|
-
* were our doing. An agent that is told the causality has two real moves
|
|
1547
|
-
* (allow_host, or the env seed + restart); one that is not, reports the
|
|
1548
|
-
* site as empty. */
|
|
1549
|
-
function blockedNote(sess, thin = false) {
|
|
1550
|
-
if (!sess.blocked.count)
|
|
1551
|
-
return "";
|
|
1552
|
-
const n = sess.blocked.count;
|
|
1553
|
-
const hosts = [...sess.blocked.hosts];
|
|
1554
|
-
// RESET IN PLACE — the engine's route handler holds this very object, so
|
|
1555
|
-
// swapping in a new one would silently stop counting after the first
|
|
1556
|
-
// drain (and the note would never appear again for the session).
|
|
1557
|
-
sess.blocked.count = 0;
|
|
1558
|
-
sess.blocked.hosts.clear();
|
|
1559
|
-
traj({ step_id: "browser", event: "blocked", count: n, hosts: hosts.slice(0, 20).join(","), thin });
|
|
1560
|
-
const list = `${hosts.slice(0, 6).join(", ")}${hosts.length > 6 ? ` …+${hosts.length - 6}` : ""}`;
|
|
1561
|
-
const base = `\n注意:本页有 ${n} 个子资源请求被治理白名单拦截(${list})——不是站点没有内容。` +
|
|
1562
|
-
`当前策略 TM_BROWSER_SUBRESOURCE=${subPolicy};同域资源已自动放行,跨域脚本仍需白名单或弹窗批准。`;
|
|
1563
|
-
if (!thin)
|
|
1564
|
-
return base;
|
|
1565
|
-
return (base +
|
|
1566
|
-
`这一页的可寻址内容为 0,而被拦的正是页面自己的脚本域名——空白是我们的门禁造成的,不是页面没有内容。` +
|
|
1567
|
-
`两条出路:action:"allow_host" {host:"${hosts[0] ?? "被拦域名"}"} 由我拿官方确认窗去问用户(只放行这个域名、只对你这个浏览器、本次会话),` +
|
|
1568
|
-
`或请用户把域名加进 TM_WEBFETCH_ALLOWED_DOMAINS 后重启 OpenCode。批准后要重新 navigate 一次——门禁在请求时判定,已加载的页面不会自己补回来。` +
|
|
1569
|
-
`在拿到真实内容之前不要把这一页当成"网站没有内容"汇报。`);
|
|
1570
|
-
}
|
|
1571
|
-
/** A thin page may be a wall rather than an empty site. Say which, because
|
|
1572
|
-
* the next move is the opposite: retry an empty page, never a captcha. */
|
|
1573
|
-
function wallNote(...texts) {
|
|
1574
|
-
const wall = challengeWallOf(...texts);
|
|
1575
|
-
if (!wall)
|
|
1576
|
-
return "";
|
|
1577
|
-
traj({ step_id: "browser", event: "challenge_wall", sign: wall });
|
|
1578
|
-
return (`\n这一页不是"没有内容",它是一张${wall}墙——站点要求真人完成验证,自动化通道到此为止。` +
|
|
1579
|
-
`不要重试这一页来"等它加载完",也不要把"该站点无内容"写进结论:换来源(tm_search 的其它引擎 / 别的站点),` +
|
|
1580
|
-
`或者告诉用户需要他本人在这个窗口里完成一次验证后再继续。`);
|
|
1581
|
-
}
|
|
1582
|
-
function pageTextOutput(url, text) {
|
|
1583
|
-
return `页面文本(${url}):\n${text.slice(0, 20000)}${text.length > 20000 ? "\n…(截断)" : ""}`;
|
|
1584
|
-
}
|
|
1585
|
-
function discoveryError() {
|
|
1586
|
-
return new Error("本机未找到可用的浏览器(按 Edge/Chrome/Chromium 顺序探测失败)。" +
|
|
1587
|
-
"设置 TM_BROWSER_PATH 指向浏览器可执行文件,或改用 tm_webfetch / 用户 MCP 工具。");
|
|
1588
|
-
}
|
|
1589
|
-
/**
|
|
1590
|
-
* M1 (upload_file exfil): the domain allowlist constrains the network
|
|
1591
|
-
* DESTINATION but never the local SOURCE — an unguarded filePath could
|
|
1592
|
-
* upload .env / key files to a whitelisted host's form. Reuses the read
|
|
1593
|
-
* pipeline's P2 containment verbatim (workspace root + blackboard +
|
|
1594
|
-
* trajectory; realpath-verified, fail-closed on unresolvable paths) and
|
|
1595
|
-
* then applies the R6 env-file classifier, so *.env / shell-rc files are
|
|
1596
|
-
* refused INSIDE the root too. The env refusal echoes no path (R6 privacy
|
|
1597
|
-
* line); the scope verdict keeps the tm_read/tm_grep message shape.
|
|
1598
|
-
*/
|
|
1599
|
-
function uploadExfilGuard(args, ctx) {
|
|
1600
|
-
const raw = (args.filePath ?? args.files);
|
|
1601
|
-
const files = (Array.isArray(raw) ? raw : [raw]).map((f) => String(f ?? "").trim()).filter(Boolean);
|
|
1602
|
-
if (!files.length)
|
|
1603
|
-
return null; // "缺少 filePath" stays act()'s own args error
|
|
1604
|
-
const d = ctx?.directory;
|
|
1605
|
-
const root = typeof d === "string" && d ? d : process.cwd();
|
|
1606
|
-
const extra = [pipelines.store.blackboardRoot, pipelines.store.trajectoryRoot].filter((p) => typeof p === "string" && p.length > 0);
|
|
1607
|
-
for (const f of files) {
|
|
1608
|
-
if (isEnvFilePath(f))
|
|
1609
|
-
return "拒绝上传(R6 红线:.env / shell rc 家族文件永不离开本机)";
|
|
1610
|
-
const scope = assertReadablePath(root, f, extra);
|
|
1611
|
-
if (!scope.ok)
|
|
1612
|
-
return scope.message;
|
|
1613
|
-
}
|
|
1614
|
-
return null;
|
|
1615
|
-
}
|
|
1616
|
-
// ================= cdp-legacy engine (原手写 CDP 管道,保留不删) =============
|
|
1617
|
-
/**
|
|
1618
|
-
* Shared network verdict for BOTH engines — one implementation, so the
|
|
1619
|
-
* playwright and cdp-legacy legs can never disagree about what passes.
|
|
1620
|
-
* The hard red lines (non-http scheme, remote env-file spelling) stay hard
|
|
1621
|
-
* under EVERY subresource policy: they are not consentable, so a passive
|
|
1622
|
-
* image or a same-site script can never relax them.
|
|
1623
|
-
*/
|
|
1624
|
-
function gateDecide(st, url, resourceType, approvedHosts) {
|
|
1625
|
-
const verdict = checkWebUrl(url, allowlist);
|
|
1626
|
-
if (!verdict.ok && !verdict.askable)
|
|
1627
|
-
return { pass: false, via: "red-line" };
|
|
1628
|
-
let approved = false;
|
|
1629
|
-
try {
|
|
1630
|
-
approved = approvedHosts.has(new URL(url).hostname);
|
|
1631
|
-
}
|
|
1632
|
-
catch {
|
|
1633
|
-
approved = false;
|
|
1634
|
-
}
|
|
1635
|
-
const v = subresourcePass({
|
|
1636
|
-
url,
|
|
1637
|
-
resourceType,
|
|
1638
|
-
policy: subPolicy,
|
|
1639
|
-
allowlistHit: verdict.ok,
|
|
1640
|
-
approvedHost: approved,
|
|
1641
|
-
allowedSites: st.allowedSites,
|
|
1642
|
-
});
|
|
1643
|
-
if (v.pass) {
|
|
1644
|
-
if (normalizeResourceType(resourceType) === "document")
|
|
1645
|
-
rememberSite(st.allowedSites, url);
|
|
1646
|
-
}
|
|
1647
|
-
else {
|
|
1648
|
-
st.blocked.count++;
|
|
1649
|
-
try {
|
|
1650
|
-
st.blocked.hosts.add(new URL(url).hostname);
|
|
1651
|
-
}
|
|
1652
|
-
catch {
|
|
1653
|
-
/* no host to name */
|
|
1654
|
-
}
|
|
1655
|
-
}
|
|
1656
|
-
// NO per-request trajectory: a real page is hundreds of requests and the
|
|
1657
|
-
// log would be pure noise. Blocked verdicts aggregate into st.blocked
|
|
1658
|
-
// and flush as ONE line (blockedNote / close).
|
|
1659
|
-
return v;
|
|
1660
|
-
}
|
|
1661
|
-
async function openLegacySession(headless, approvedHosts) {
|
|
1662
|
-
const executable = discover(env);
|
|
1663
|
-
if (!executable)
|
|
1664
|
-
throw discoveryError();
|
|
1665
|
-
// The SAME knob on BOTH engines. This used to be temp-only, so a session
|
|
1666
|
-
// that degraded to cdp-legacy kept its logins nowhere while the reply still
|
|
1667
|
-
// read the env and announced 持久登录配置.
|
|
1668
|
-
const profile = classifyProfileDir(env);
|
|
1669
|
-
if (profile.refusal)
|
|
1670
|
-
throw new Error(profile.refusal);
|
|
1671
|
-
const persistent = profile.dir !== null;
|
|
1672
|
-
const profileDir = persistent ? profile.dir : fs.mkdtempSync(path.join(os.tmpdir(), "tm-browser-"));
|
|
1673
|
-
const child = spawn(executable, [
|
|
1674
|
-
headless ? "--headless=new" : "--start-maximized",
|
|
1675
|
-
"--remote-debugging-pipe",
|
|
1676
|
-
`--user-data-dir=${profileDir}`,
|
|
1677
|
-
// real-Chrome UA even under --headless=new (which some sites detect
|
|
1678
|
-
// via the "HeadlessChrome" token otherwise) + zh-CN Accept-Language
|
|
1679
|
-
`--user-agent=${HARDENED_UA}`,
|
|
1680
|
-
"--accept-lang=zh-CN,zh;q=0.9,en;q=0.8",
|
|
1681
|
-
"--no-first-run", "--no-default-browser-check", "--disable-extensions",
|
|
1682
|
-
"--disable-background-networking", "--mute-audio",
|
|
1683
|
-
BROWSER_DENY_PERMISSIONS_ARG,
|
|
1684
|
-
"about:blank",
|
|
1685
|
-
], { stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"], windowsHide: true });
|
|
1686
|
-
const cdp = new PipeCdp(child);
|
|
1687
|
-
const t0 = Date.now();
|
|
1688
|
-
// ready loop: the pipe is not immediately writable-to-a-live-target
|
|
1689
|
-
for (;;) {
|
|
1690
|
-
try {
|
|
1691
|
-
await cdp.call("Target.getTargets", {}, undefined, 3000);
|
|
1692
|
-
break;
|
|
1693
|
-
}
|
|
1694
|
-
catch (e) {
|
|
1695
|
-
if (Date.now() - t0 > SPAWN_TIMEOUT_MS || child.exitCode !== null) {
|
|
1696
|
-
child.kill();
|
|
1697
|
-
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
1698
|
-
throw new Error(`浏览器启动失败:${e.message}`);
|
|
1699
|
-
}
|
|
1700
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
const targets = await cdp.call("Target.getTargets");
|
|
1704
|
-
const page = targets.targetInfos.find((t) => t.type === "page");
|
|
1705
|
-
if (!page)
|
|
1706
|
-
throw new Error("浏览器启动后未找到 page target");
|
|
1707
|
-
const { sessionId } = (await cdp.call("Target.attachToTarget", { targetId: page.targetId, flatten: true }));
|
|
1708
|
-
await cdp.call("Page.enable", {}, sessionId);
|
|
1709
|
-
// network-layer enforcement — the SAME gateDecide the playwright leg uses
|
|
1710
|
-
// (subresource policy + per-hop redirect re-check + dialog-approved hosts)
|
|
1711
|
-
const allowedSites = new Set();
|
|
1712
|
-
const blocked = { count: 0, hosts: new Set() };
|
|
1713
|
-
await cdp.call("Fetch.enable", { patterns: [{ urlPattern: "*" }] }, sessionId);
|
|
1714
|
-
cdp.onEvent((m) => {
|
|
1715
|
-
if (m.method !== "Fetch.requestPaused" || m.sessionId !== sessionId)
|
|
1716
|
-
return;
|
|
1717
|
-
const requestId = String(m.params.requestId ?? "");
|
|
1718
|
-
const p = m.params.request ?? {};
|
|
1719
|
-
const url = String(p.url ?? "");
|
|
1720
|
-
const { pass } = gateDecide({ allowedSites, blocked }, url, p.resourceType, approvedHosts);
|
|
1721
|
-
if (pass) {
|
|
1722
|
-
void cdp.call("Fetch.continueRequest", { requestId }, sessionId, 5000).catch(() => { });
|
|
1723
|
-
}
|
|
1724
|
-
else {
|
|
1725
|
-
void cdp.call("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }, sessionId, 5000).catch(() => { });
|
|
1726
|
-
}
|
|
1727
|
-
});
|
|
1728
|
-
recordBrowser(Number(child.pid ?? 0), "cdp-legacy", executable);
|
|
1729
|
-
const sess = {
|
|
1730
|
-
kind: "cdp-legacy",
|
|
1731
|
-
currentUrl: "about:blank",
|
|
1732
|
-
headless,
|
|
1733
|
-
persistentProfile: persistent ? profileDir : null,
|
|
1734
|
-
label: `cdp-legacy · ${path.basename(executable)} · pid ${child.pid ?? "?"}`,
|
|
1735
|
-
attachments: [],
|
|
1736
|
-
blocked,
|
|
1737
|
-
allowedSites,
|
|
1738
|
-
// the CDP child still running IS the browser being alive on this engine
|
|
1739
|
-
alive: () => child.exitCode === null,
|
|
1740
|
-
async act(action, args, stepId) {
|
|
1741
|
-
if (action === "navigate" || action === "navigate_page") {
|
|
1742
|
-
const url = String(args.url ?? "").trim();
|
|
1743
|
-
rememberSite(allowedSites, url);
|
|
1744
|
-
await cdp.call("Page.navigate", { url }, sessionId);
|
|
1745
|
-
await cdp.waitEvent("Page.loadEventFired", sessionId, NAVIGATE_EVENT_TIMEOUT_MS).catch(() => { });
|
|
1746
|
-
sess.currentUrl = url;
|
|
1747
|
-
return `已导航:${url}${blockedNote(sess)}`;
|
|
1748
|
-
}
|
|
1749
|
-
if (action === "read") {
|
|
1750
|
-
traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
|
|
1751
|
-
const ev = await cdp.call("Runtime.evaluate", { expression: "document.body ? document.body.innerText : ''", returnByValue: true }, sessionId);
|
|
1752
|
-
const text = String(ev.result?.value ?? "");
|
|
1753
|
-
traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
|
|
1754
|
-
// Same diagnosis as the playwright leg — the two engines must not
|
|
1755
|
-
// drift on what a blank page is allowed to be called.
|
|
1756
|
-
const thin = text.replace(/\s+/g, "").length < 80;
|
|
1757
|
-
let title = "";
|
|
1758
|
-
if (thin) {
|
|
1759
|
-
const t = await cdp
|
|
1760
|
-
.call("Runtime.evaluate", { expression: "document.title", returnByValue: true }, sessionId)
|
|
1761
|
-
.catch(() => null);
|
|
1762
|
-
title = String(t?.result?.value ?? "");
|
|
1763
|
-
}
|
|
1764
|
-
return `${pageTextOutput(sess.currentUrl, text)}${wallNote(title, text)}${blockedNote(sess, thin)}`;
|
|
1765
|
-
}
|
|
1766
|
-
if (action === "screenshot" || action === "take_screenshot") {
|
|
1767
|
-
traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
|
|
1768
|
-
const shot = await cdp.call("Page.captureScreenshot", { format: "png" }, sessionId);
|
|
1769
|
-
const png = Buffer.from(String(shot.data ?? ""), "base64");
|
|
1770
|
-
const { file } = saveShotPng(new Uint8Array(png), stepId);
|
|
1771
|
-
return shotOutput(sess, file, png, args, async () => {
|
|
1772
|
-
const j = await cdp.call("Page.captureScreenshot", { format: "jpeg", quality: 70 }, sessionId);
|
|
1773
|
-
return Buffer.from(String(j.data ?? ""), "base64");
|
|
1774
|
-
});
|
|
1775
|
-
}
|
|
1776
|
-
if (action === "list_pages") {
|
|
1777
|
-
const t = await cdp.call("Target.getTargets");
|
|
1778
|
-
const pages = t.targetInfos.filter((x) => x.type === "page");
|
|
1779
|
-
const lines = pages.map((p, i) => `${i}: ${String(p.url ?? "")} ${String(p.title ?? "")}`.trimEnd());
|
|
1780
|
-
return `标签页(${pages.length}):\n${lines.join("\n") || "(无)"}`;
|
|
1781
|
-
}
|
|
1782
|
-
if (action === "evaluate_script") {
|
|
1783
|
-
const src = String(args.function ?? args.expression ?? "").trim();
|
|
1784
|
-
if (!src)
|
|
1785
|
-
throw new Error('缺少 expression/function 参数(evaluate_script 需要一个 JS 表达式)');
|
|
1786
|
-
traj({ step_id: stepId, event: "call", kind: "evaluate" });
|
|
1787
|
-
const ev = await cdp.call("Runtime.evaluate", { expression: evalExpression(src), returnByValue: true, awaitPromise: true }, sessionId);
|
|
1788
|
-
// A page-side throw used to render as "执行结果:null", which is how an
|
|
1789
|
-
// agent spends six rounds wondering whether the selector was wrong.
|
|
1790
|
-
const det = ev?.exceptionDetails;
|
|
1791
|
-
if (det) {
|
|
1792
|
-
const first = String(det.exception?.description ?? det.text ?? JSON.stringify(det))
|
|
1793
|
-
.split("\n")[0]
|
|
1794
|
-
.slice(0, 300);
|
|
1795
|
-
throw new Error(`页面内抛出异常(不是"没找到"):${first}`);
|
|
1796
|
-
}
|
|
1797
|
-
return capTokens(renderEvalResult(ev.result?.value), "…(结果超长已截断)");
|
|
1798
|
-
}
|
|
1799
|
-
throw new Error(`action "${action}" 需要 playwright 引擎(当前为 cdp-legacy 降级:playwright-core 未安装或 node<20)。` +
|
|
1800
|
-
`cdp-legacy 可用: ${BROWSER_LEGACY_ACTIONS.filter((a) => a !== "open" && a !== "close").join(" | ")}`);
|
|
1801
|
-
},
|
|
1802
|
-
async close() {
|
|
1803
|
-
child.off?.("exit", onExit);
|
|
1804
|
-
await cdp.call("Browser.close", {}, undefined, 3000).catch(() => { });
|
|
1805
|
-
if (child.exitCode === null) {
|
|
1806
|
-
child.kill();
|
|
1807
|
-
}
|
|
1808
|
-
// wait briefly for the process to release the profile (Windows file
|
|
1809
|
-
// locks linger while the browser is still shutting down)
|
|
1810
|
-
await new Promise((res) => {
|
|
1811
|
-
if (child.exitCode !== null)
|
|
1812
|
-
return res();
|
|
1813
|
-
const t = setTimeout(res, 3000);
|
|
1814
|
-
child.once("exit", () => {
|
|
1815
|
-
clearTimeout(t);
|
|
1816
|
-
res();
|
|
1817
|
-
});
|
|
1818
|
-
});
|
|
1819
|
-
const dead = child.exitCode !== null;
|
|
1820
|
-
// Only a directory WE created is ours to remove. Deleting a user-named
|
|
1821
|
-
// persistent profile on close would destroy the logins the knob exists
|
|
1822
|
-
// to keep.
|
|
1823
|
-
if (!persistent) {
|
|
1824
|
-
try {
|
|
1825
|
-
rmForceSafe(profileDir, { recursive: true });
|
|
1826
|
-
}
|
|
1827
|
-
catch {
|
|
1828
|
-
/* a locked leftover temp dir is reclaimed by the OS — never fail close */
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
return dead
|
|
1832
|
-
? {
|
|
1833
|
-
closed: true,
|
|
1834
|
-
text: persistent
|
|
1835
|
-
? `浏览器进程已退出(cdp-legacy);持久配置目录已保留(下次 open 复用登录态):${profileDir}。`
|
|
1836
|
-
: "浏览器进程已退出(cdp-legacy),临时配置目录已清理。",
|
|
1837
|
-
}
|
|
1838
|
-
: {
|
|
1839
|
-
closed: false,
|
|
1840
|
-
text: `警告:cdp-legacy 子进程 pid ${child.pid ?? "?"} 未在 3s 内退出,窗口可能仍在前台——` +
|
|
1841
|
-
`请手动关闭该浏览器窗口(配置目录 ${profileDir}${persistent ? ",持久目录我不会删" : ",临时目录"})。`,
|
|
1842
|
-
};
|
|
1843
|
-
},
|
|
1844
|
-
};
|
|
1845
|
-
const onExit = () => {
|
|
1846
|
-
dropSession(sess);
|
|
1847
|
-
// best-effort: the profile may still be file-locked during shutdown —
|
|
1848
|
-
// a leftover temp dir is reclaimed by the OS, never an error for the task
|
|
1849
|
-
try {
|
|
1850
|
-
rmForceSafe(profileDir, { recursive: true });
|
|
1851
|
-
}
|
|
1852
|
-
catch {
|
|
1853
|
-
/* ignore */
|
|
1854
|
-
}
|
|
1855
|
-
};
|
|
1856
|
-
child.on("exit", onExit);
|
|
1857
|
-
return sess;
|
|
1858
|
-
}
|
|
1859
|
-
// ================= playwright engine (primary) ==============================
|
|
1860
|
-
async function openPlaywrightSession(pw, headless, approvedHosts) {
|
|
1861
|
-
const executable = discover(env);
|
|
1862
|
-
if (!executable)
|
|
1863
|
-
throw discoveryError();
|
|
1864
|
-
const launchArgs = [
|
|
1865
|
-
"--no-first-run", "--no-default-browser-check", "--disable-extensions",
|
|
1866
|
-
"--disable-background-networking", "--mute-audio",
|
|
1867
|
-
BROWSER_DENY_PERMISSIONS_ARG,
|
|
1868
|
-
...(headless ? [] : ["--start-maximized"]),
|
|
1869
|
-
];
|
|
1870
|
-
const contextOpts = {
|
|
1871
|
-
userAgent: HARDENED_UA,
|
|
1872
|
-
locale: "zh-CN",
|
|
1873
|
-
extraHTTPHeaders: { "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" },
|
|
1874
|
-
// the 3000 ms observation budget is the engine default (see header)
|
|
1875
|
-
timeout: PW_DEFAULT_TIMEOUT_MS,
|
|
1876
|
-
// headful: honor --start-maximized (null viewport = the OS window size)
|
|
1877
|
-
viewport: headless ? undefined : null,
|
|
1878
|
-
};
|
|
1879
|
-
const profile = classifyProfileDir(env);
|
|
1880
|
-
if (profile.refusal)
|
|
1881
|
-
throw new Error(profile.refusal);
|
|
1882
|
-
const persistentDir = profile.dir ?? "";
|
|
1883
|
-
// R2 (revised 2026-09-18): playwright's `channel` resolves the executable
|
|
1884
|
-
// ITSELF, which silently overrode our discovery (a Beta-default Windows
|
|
1885
|
-
// host opened STABLE Edge). executablePath is now the primary launch and
|
|
1886
|
-
// the channel spelling only a fallback — the opposite of the old order.
|
|
1887
|
-
const target = playwrightLaunchTarget(executable, {
|
|
1888
|
-
explicitOverride: String(env.TM_BROWSER_PATH ?? "").trim() !== "",
|
|
1889
|
-
});
|
|
1890
|
-
let browser = null;
|
|
1891
|
-
let context;
|
|
1892
|
-
let via = "executablePath";
|
|
1893
|
-
if (persistentDir) {
|
|
1894
|
-
context = await pw.chromium.launchPersistentContext(persistentDir, {
|
|
1895
|
-
...contextOpts,
|
|
1896
|
-
executablePath: target.executablePath,
|
|
1897
|
-
headless,
|
|
1898
|
-
args: launchArgs,
|
|
1899
|
-
});
|
|
1900
|
-
via = `persistent(executablePath)`;
|
|
1901
|
-
}
|
|
1902
|
-
else {
|
|
1903
|
-
try {
|
|
1904
|
-
browser = await pw.chromium.launch({ executablePath: target.executablePath, headless, args: launchArgs });
|
|
1905
|
-
via = "executablePath";
|
|
1906
|
-
}
|
|
1907
|
-
catch (e1) {
|
|
1908
|
-
if (!target.channel)
|
|
1909
|
-
throw e1;
|
|
1910
|
-
browser = await pw.chromium.launch({ channel: target.channel, headless, args: launchArgs });
|
|
1911
|
-
via = `channel(${target.channel})`;
|
|
1912
|
-
}
|
|
1913
|
-
context = await browser.newContext(contextOpts);
|
|
1914
|
-
}
|
|
1915
|
-
const snapIndex = new SnapshotIndex();
|
|
1916
|
-
// The OS process behind the connection. playwright exposes it as
|
|
1917
|
-
// browser.process(); on the persistent path the browser is reached through
|
|
1918
|
-
// the context. Without a pid there is no way to tell "the CDP connection
|
|
1919
|
-
// dropped" apart from "the browser is gone" — and they are NOT the same
|
|
1920
|
-
// thing (measured: close printed 已确认关闭 while the msedge tree was still
|
|
1921
|
-
// alive under OpenCode.exe).
|
|
1922
|
-
const owningBrowser = browser ??
|
|
1923
|
-
(() => {
|
|
1924
|
-
try {
|
|
1925
|
-
return context.browser?.() ?? null;
|
|
1926
|
-
}
|
|
1927
|
-
catch {
|
|
1928
|
-
return null;
|
|
1929
|
-
}
|
|
1930
|
-
})();
|
|
1931
|
-
const { pid: browserPid, via: pidRoute, failures: pidFailures } = await resolveLaunchPid({
|
|
1932
|
-
// playwright's own accessor first — it does not exist on 1.63, so the OS
|
|
1933
|
-
// scan is what answers today. Which route replied is AUDITED: a silent
|
|
1934
|
-
// fall-back to 0 would put us straight back to printing 已确认关闭 about a
|
|
1935
|
-
// process nobody looked at.
|
|
1936
|
-
playwrightPid: () => Number(owningBrowser?.process?.()?.pid ?? 0) || 0,
|
|
1937
|
-
executablePath: target.executablePath ?? executable,
|
|
1938
|
-
rows: () => (deps.listChildren ? deps.listChildren(process.pid) : undefined),
|
|
1939
|
-
});
|
|
1940
|
-
traj({ step_id: "browser", event: "launch_pid", via: pidRoute, pid: browserPid, tries: pidFailures });
|
|
1941
|
-
recordBrowser(browserPid, persistentDir ? "playwright-persistent" : "playwright", executable);
|
|
1942
|
-
const state = {
|
|
1943
|
-
page: context.pages()[0] ?? (await context.newPage()),
|
|
1944
|
-
dialogs: [],
|
|
1945
|
-
consoleBuf: [],
|
|
1946
|
-
netBuf: [],
|
|
1947
|
-
allowedSites: new Set(),
|
|
1948
|
-
blocked: { count: 0, hosts: new Set() },
|
|
1949
|
-
};
|
|
1950
|
-
const attached = new WeakSet();
|
|
1951
|
-
const attach = (p) => {
|
|
1952
|
-
if (attached.has(p))
|
|
1953
|
-
return; // select_page may bounce back to an already-hooked tab
|
|
1954
|
-
attached.add(p);
|
|
1955
|
-
p.on("console", (msg) => {
|
|
1956
|
-
state.consoleBuf.push({ type: String(msg?.type?.() ?? "log"), text: String(msg?.text?.() ?? "") });
|
|
1957
|
-
if (state.consoleBuf.length > OBSERVE_BUFFER_CAP)
|
|
1958
|
-
state.consoleBuf.shift();
|
|
1959
|
-
});
|
|
1960
|
-
p.on("pageerror", (err) => {
|
|
1961
|
-
state.consoleBuf.push({ type: "pageerror", text: String(err?.message ?? err) });
|
|
1962
|
-
if (state.consoleBuf.length > OBSERVE_BUFFER_CAP)
|
|
1963
|
-
state.consoleBuf.shift();
|
|
1964
|
-
});
|
|
1965
|
-
p.on("request", (req) => {
|
|
1966
|
-
state.netBuf.push({ method: req?.method?.() ?? "?", url: String(req?.url?.() ?? "").slice(0, 300) });
|
|
1967
|
-
if (state.netBuf.length > OBSERVE_BUFFER_CAP)
|
|
1968
|
-
state.netBuf.shift();
|
|
1969
|
-
});
|
|
1970
|
-
p.on("response", (res) => {
|
|
1971
|
-
state.netBuf.push({
|
|
1972
|
-
method: res?.request?.()?.method?.() ?? "?",
|
|
1973
|
-
url: String(res?.url?.() ?? "").slice(0, 300),
|
|
1974
|
-
status: Number(res?.status?.() ?? 0) || undefined,
|
|
1975
|
-
});
|
|
1976
|
-
if (state.netBuf.length > OBSERVE_BUFFER_CAP)
|
|
1977
|
-
state.netBuf.shift();
|
|
1978
|
-
});
|
|
1979
|
-
p.on("requestfailed", (req) => {
|
|
1980
|
-
state.netBuf.push({ method: req?.method?.() ?? "?", url: String(req?.url?.() ?? "").slice(0, 300), failure: "requestfailed" });
|
|
1981
|
-
if (state.netBuf.length > OBSERVE_BUFFER_CAP)
|
|
1982
|
-
state.netBuf.shift();
|
|
1983
|
-
});
|
|
1984
|
-
// dialogs are HELD for handle_dialog (registering the handler opts us
|
|
1985
|
-
// out of playwright's implicit auto-dismiss)
|
|
1986
|
-
p.on("dialog", (d) => {
|
|
1987
|
-
state.dialogs.push(d);
|
|
1988
|
-
if (state.dialogs.length > 8)
|
|
1989
|
-
state.dialogs.shift();
|
|
1990
|
-
});
|
|
1991
|
-
};
|
|
1992
|
-
attach(state.page);
|
|
1993
|
-
// ---- network-layer allowlist: context.route() (CDP Fetch 的迁移面) ----
|
|
1994
|
-
const routeHandler = async (route, request) => {
|
|
1995
|
-
const u = String(request.url?.() ?? "");
|
|
1996
|
-
// data:/blob:/about: never reach playwright's route anyway; keep the
|
|
1997
|
-
// non-http hard-reject semantics identical to the CDP-era behavior
|
|
1998
|
-
if (/^(about|data|blob):/i.test(u)) {
|
|
1999
|
-
await route.continue().catch(() => { });
|
|
2000
|
-
return;
|
|
2001
|
-
}
|
|
2002
|
-
const { pass } = gateDecide(state, u, request.resourceType?.(), approvedHosts);
|
|
2003
|
-
if (pass) {
|
|
2004
|
-
await route.continue().catch(() => { });
|
|
2005
|
-
}
|
|
2006
|
-
else {
|
|
2007
|
-
// per-hop re-check lands here for every redirect hop too
|
|
2008
|
-
state.netBuf.push({ method: request.method?.() ?? "?", url: u.slice(0, 300), blocked: true });
|
|
2009
|
-
await route.abort().catch(() => { });
|
|
2010
|
-
}
|
|
2011
|
-
};
|
|
2012
|
-
// M2 (fix round) fail-closed: the route gate is playwright's analog of
|
|
2013
|
-
// the CDP leg's AWAITED Fetch.enable — if registration fails the session
|
|
2014
|
-
// would browse with NO network whitelist. Close the context (persistent:
|
|
2015
|
-
// the only handle; launched: plus the browser) and surface an execute
|
|
2016
|
-
// error; a swallowed .catch(()=>{}) was a fail-open hole.
|
|
2017
|
-
try {
|
|
2018
|
-
await Promise.resolve(context.route("**/*", routeHandler));
|
|
2019
|
-
}
|
|
2020
|
-
catch (e) {
|
|
2021
|
-
await Promise.resolve(context.close()).catch(() => { });
|
|
2022
|
-
if (browser)
|
|
2023
|
-
await Promise.resolve(browser.close()).catch(() => { });
|
|
2024
|
-
traj({ step_id: "browser", event: "gate_register_failed", reason: String(e?.message ?? e).slice(0, 200) });
|
|
2025
|
-
throw new Error(`网络白名单闸注册失败(context.route)——已 fail-closed 关闭浏览器会话,不会留下无白名单的窗口。` +
|
|
2026
|
-
`原因:${String(e?.message ?? e)}`);
|
|
2027
|
-
}
|
|
2028
|
-
/** uid → locator with the snapshot-first guidance errors (R2: 禁猜
|
|
2029
|
-
* locator — only uid addressing or an explicit selector escape). */
|
|
2030
|
-
const targetOf = (uidKey, selKey, args) => {
|
|
2031
|
-
const uid = String(args[uidKey] ?? "").trim();
|
|
2032
|
-
if (uid) {
|
|
2033
|
-
const loc = snapIndex.locatorFor(state.page, uid);
|
|
2034
|
-
if (!loc) {
|
|
2035
|
-
throw new Error(`uid "${uid}" 不在当前快照中(快照会重新编号)——先 action:"take_snapshot" 再用返回的 [uid=...] 寻址`);
|
|
2036
|
-
}
|
|
2037
|
-
return loc;
|
|
2038
|
-
}
|
|
2039
|
-
const sel = String(args[selKey] ?? "").trim();
|
|
2040
|
-
if (sel)
|
|
2041
|
-
return state.page.locator(sel);
|
|
2042
|
-
throw new Error(`缺少 ${uidKey}/selector——tm_browser 快照优先:先 action:"take_snapshot",动作只按快照里的 [uid=...] 寻址(或显式 selector 逃生舱),禁止猜 locator`);
|
|
2043
|
-
};
|
|
2044
|
-
/** Human-readable addressing echo for action results (no full locators
|
|
2045
|
-
* into context — uid tokens only). */
|
|
2046
|
-
const describeTarget = (args, uidKey = "uid") => {
|
|
2047
|
-
const uid = String(args[uidKey] ?? "").trim();
|
|
2048
|
-
if (uid)
|
|
2049
|
-
return `uid "${uid}"`;
|
|
2050
|
-
const sel = String(args[uidKey === "uid" ? "selector" : "targetSelector"] ?? "").trim();
|
|
2051
|
-
return sel ? `selector "${sel.slice(0, 60)}"` : "目标";
|
|
2052
|
-
};
|
|
2053
|
-
const sess = {
|
|
2054
|
-
kind: "playwright",
|
|
2055
|
-
currentUrl: "about:blank",
|
|
2056
|
-
headless,
|
|
2057
|
-
persistentProfile: persistentDir || null,
|
|
2058
|
-
label: `playwright/${via} · ${path.basename(executable)} · ${headless ? "headless" : "headful"}`,
|
|
2059
|
-
attachments: [],
|
|
2060
|
-
blocked: state.blocked,
|
|
2061
|
-
allowedSites: state.allowedSites,
|
|
2062
|
-
// The browser object is the truth about liveness; a closed TAB is not it.
|
|
2063
|
-
alive: () => owningBrowser?.isConnected?.() !== false,
|
|
2064
|
-
async act(action, args, stepId) {
|
|
2065
|
-
// A context with zero pages is ALIVE — it simply has no tab. Addressing
|
|
2066
|
-
// the tab close_page removed threw "Target page, context or browser has
|
|
2067
|
-
// been closed", which the recovery in execute() reads as a dead BROWSER:
|
|
2068
|
-
// the lease was dropped, `close` then reported "no browser open", and the
|
|
2069
|
-
// user kept a window nothing was claiming any more (measured live,
|
|
2070
|
-
// 1-6-0-click.json step 11 — right after our own reply promised
|
|
2071
|
-
// "浏览器仍在运行"). An empty window gets its own honest answer here and
|
|
2072
|
-
// keeps its lease.
|
|
2073
|
-
if (!ZERO_TAB_SAFE.has(action) && context.pages().length === 0) {
|
|
2074
|
-
throw new Error("这个浏览器已经没有标签页了(0 个,窗口和进程都还在,你的租约我没有丢)。" +
|
|
2075
|
-
'要继续用 new_page 在同一个窗口开一个标签;要结束整个会话用 action:"close"(它会验进程真的退出)。');
|
|
2076
|
-
}
|
|
2077
|
-
if (action === "navigate" || action === "navigate_page") {
|
|
2078
|
-
const url = String(args.url ?? "").trim();
|
|
2079
|
-
// the navigation itself already cleared the allowlist (execute()
|
|
2080
|
-
// asked the user otherwise) — its SITE may now load subresources
|
|
2081
|
-
rememberSite(state.allowedSites, url);
|
|
2082
|
-
// domcontentloaded ONLY — networkidle is banned (prompt discipline)
|
|
2083
|
-
await state.page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
|
2084
|
-
sess.currentUrl = state.page.url?.() || url;
|
|
2085
|
-
rememberSite(state.allowedSites, sess.currentUrl);
|
|
2086
|
-
return `已导航:${url}${blockedNote(sess)}`;
|
|
2087
|
-
}
|
|
2088
|
-
if (action === "read") {
|
|
2089
|
-
traj({ step_id: stepId, event: "call", url: sess.currentUrl.slice(0, 200) });
|
|
2090
|
-
const text = String((await state.page.evaluate("document.body ? document.body.innerText : ''")) ?? "");
|
|
2091
|
-
traj({ step_id: stepId, event: "result", tokens: Math.ceil(text.length / 4) });
|
|
2092
|
-
const thin = text.replace(/\s+/g, "").length < 80;
|
|
2093
|
-
const title = thin ? await readTitle(state.page) : "";
|
|
2094
|
-
return `${pageTextOutput(sess.currentUrl, text)}${wallNote(title, text)}${blockedNote(sess, thin)}`;
|
|
2095
|
-
}
|
|
2096
|
-
if (action === "screenshot" || action === "take_screenshot") {
|
|
2097
|
-
traj({ step_id: stepId, event: "call", kind: "screenshot", url: sess.currentUrl.slice(0, 200) });
|
|
2098
|
-
const bytes = await state.page.screenshot({ type: "png", fullPage: Boolean(args.fullPage) });
|
|
2099
|
-
const { file, png } = saveShotPng(bytes, stepId);
|
|
2100
|
-
return shotOutput(sess, file, png, args, async () => Buffer.from(await state.page.screenshot({ type: "jpeg", quality: 70, fullPage: Boolean(args.fullPage) })));
|
|
2101
|
-
}
|
|
2102
|
-
if (action === "take_snapshot") {
|
|
2103
|
-
traj({ step_id: stepId, event: "call", kind: "snapshot", url: sess.currentUrl.slice(0, 200) });
|
|
2104
|
-
let yaml;
|
|
2105
|
-
try {
|
|
2106
|
-
yaml = await state.page.locator("body").ariaSnapshot();
|
|
2107
|
-
}
|
|
2108
|
-
catch (e) {
|
|
2109
|
-
const m = String(e?.message ?? e);
|
|
2110
|
-
// Only talk about VERSIONS when the accessor itself is missing. It
|
|
2111
|
-
// threw "…has been closed" live and blamed playwright-core 1.63 for
|
|
2112
|
-
// it, which sent the agent off to check dependencies instead of
|
|
2113
|
-
// looking at the page — a wrong diagnosis costs more than none.
|
|
2114
|
-
throw new Error(/not a function|is not defined|unsupported|no method/i.test(m)
|
|
2115
|
-
? `ariaSnapshot 不可用:${m} —— locator.ariaSnapshot 需要 playwright-core ≥1.49(本包 optionalDependencies 锁 1.63);未安装或过旧会走 cdp-legacy 降级,那边没有快照动作。`
|
|
2116
|
-
: `快照读取失败:${m.slice(0, 300)}`);
|
|
2117
|
-
}
|
|
2118
|
-
const annotated = snapIndex.annotate(yaml);
|
|
2119
|
-
traj({ step_id: stepId, event: "result", tokens: estimateTokens(annotated), nodes: snapIndex.size });
|
|
2120
|
-
const body = capTokens(annotated, `…(快照超过 browserSnapshotMaxTokens=${snapshotBudget},后续行已截断——用 read 拿原文或先滚动再快照)`);
|
|
2121
|
-
const hint = state.dialogs.length ? `\n注意:有 ${state.dialogs.length} 个未处理对话框——先 handle_dialog` : "";
|
|
2122
|
-
// A snapshot with nothing addressable is the one case worth spending
|
|
2123
|
-
// a title read on: it is either our gate, or the site asking for a
|
|
2124
|
-
// human, and the agent has to be told which — "0 个可寻址节点" alone
|
|
2125
|
-
// reads as "this site is empty".
|
|
2126
|
-
const thin = snapIndex.size === 0;
|
|
2127
|
-
const wall = thin ? wallNote(await readTitle(state.page), yaml) : "";
|
|
2128
|
-
return `ARIA 快照(${sess.currentUrl} · ${snapIndex.size} 个可寻址节点)——后续动作按行内 [uid=eN] 寻址:\n${body}${hint}${wall}${blockedNote(sess, thin)}`;
|
|
2129
|
-
}
|
|
2130
|
-
if (action === "click") {
|
|
2131
|
-
const loc = targetOf("uid", "selector", args);
|
|
2132
|
-
const target = describeTarget(args);
|
|
2133
|
-
const before = await probeClickTarget(loc);
|
|
2134
|
-
await loc.click();
|
|
2135
|
-
const dialogOpen = state.dialogs.length > 0;
|
|
2136
|
-
let seen = before ? await observeClickEffect(loc, state.page, before) : { changed: [], after: null };
|
|
2137
|
-
let retried = false;
|
|
2138
|
-
// A delivered click that changed NOTHING is the SPA hazard this
|
|
2139
|
-
// verification exists for. Retrying is safe precisely BECAUSE nothing
|
|
2140
|
-
// changed — a toggle that worked is never clicked twice — and a dialog
|
|
2141
|
-
// is open means the click did land, so it is never retried.
|
|
2142
|
-
if (before && !seen.changed.length && !dialogOpen) {
|
|
2143
|
-
await settlePage(state.page);
|
|
2144
|
-
const again = await probeClickTarget(loc);
|
|
2145
|
-
await loc.click().catch(() => { });
|
|
2146
|
-
retried = true;
|
|
2147
|
-
seen = await observeClickEffect(loc, state.page, again ?? before);
|
|
2148
|
-
}
|
|
2149
|
-
traj({
|
|
2150
|
-
step_id: stepId,
|
|
2151
|
-
event: "click_verified",
|
|
2152
|
-
effective: seen.changed.length > 0,
|
|
2153
|
-
retried,
|
|
2154
|
-
probed: Boolean(before),
|
|
2155
|
-
ready: String(before?.ready ?? ""),
|
|
2156
|
-
});
|
|
2157
|
-
return clickVerdict(target, before, seen.changed, {
|
|
2158
|
-
retried,
|
|
2159
|
-
dialog: state.dialogs.length > 0,
|
|
2160
|
-
// The gate's own ledger rides along: "nothing changed" on a page
|
|
2161
|
-
// whose scripts were trimmed needs a different next move than a page
|
|
2162
|
-
// that is still loading.
|
|
2163
|
-
blocked: { count: state.blocked.count, hosts: [...state.blocked.hosts].slice(0, 5) },
|
|
2164
|
-
});
|
|
2165
|
-
}
|
|
2166
|
-
if (action === "fill") {
|
|
2167
|
-
const loc = targetOf("uid", "selector", args);
|
|
2168
|
-
const text = String(args.text ?? "");
|
|
2169
|
-
await loc.fill(text);
|
|
2170
|
-
return `已填充 ${describeTarget(args)}(${text.length} 字符)`;
|
|
2171
|
-
}
|
|
2172
|
-
if (action === "hover") {
|
|
2173
|
-
const loc = targetOf("uid", "selector", args);
|
|
2174
|
-
await loc.hover();
|
|
2175
|
-
return `已悬停 ${describeTarget(args)}`;
|
|
2176
|
-
}
|
|
2177
|
-
if (action === "drag") {
|
|
2178
|
-
const from = targetOf("uid", "selector", args);
|
|
2179
|
-
const to = targetOf("targetUid", "targetSelector", args);
|
|
2180
|
-
await from.dragTo(to);
|
|
2181
|
-
return `已拖拽 ${describeTarget(args, "uid")} → ${describeTarget(args, "targetUid")}`;
|
|
2182
|
-
}
|
|
2183
|
-
if (action === "press_key") {
|
|
2184
|
-
const key = String(args.key ?? "").trim();
|
|
2185
|
-
if (!key)
|
|
2186
|
-
throw new Error('缺少 key 参数(如 "Enter"、"Control+A")');
|
|
2187
|
-
await state.page.keyboard.press(key);
|
|
2188
|
-
return `已按键:${key}`;
|
|
2189
|
-
}
|
|
2190
|
-
if (action === "upload_file") {
|
|
2191
|
-
const loc = targetOf("uid", "selector", args);
|
|
2192
|
-
const raw = args.filePath ?? args.files;
|
|
2193
|
-
const files = (Array.isArray(raw) ? raw : [raw]).map((f) => String(f ?? "").trim()).filter(Boolean);
|
|
2194
|
-
if (!files.length)
|
|
2195
|
-
throw new Error("缺少 filePath 参数(文件或文件数组,本机路径)");
|
|
2196
|
-
for (const f of files) {
|
|
2197
|
-
if (!fs.existsSync(f))
|
|
2198
|
-
throw new Error(`上传文件不存在:${f.slice(0, 200)}`);
|
|
2199
|
-
}
|
|
2200
|
-
await loc.setInputFiles(files);
|
|
2201
|
-
return `已上传 ${files.length} 个文件到 ${describeTarget(args)}`;
|
|
2202
|
-
}
|
|
2203
|
-
if (action === "wait_for") {
|
|
2204
|
-
const uid = String(args.uid ?? "").trim();
|
|
2205
|
-
const text = String(args.text ?? "").trim();
|
|
2206
|
-
const timeout = Math.min(30_000, Math.max(100, Number(args.timeoutMs) || PW_DEFAULT_TIMEOUT_MS));
|
|
2207
|
-
const what = uid ? `uid "${uid}"` : text ? `文本 "${text.slice(0, 80)}"` : "";
|
|
2208
|
-
if (!uid && !text)
|
|
2209
|
-
throw new Error('wait_for 需要 text 或 uid');
|
|
2210
|
-
const base = uid ? targetOf("uid", "selector", { uid }) : state.page.getByText(text);
|
|
2211
|
-
// A visible label matches several nodes on a real page, and playwright's
|
|
2212
|
-
// STRICT mode answers that by throwing its whole internal report at the
|
|
2213
|
-
// agent — measured live, where `wait_for` was the very recovery step our
|
|
2214
|
-
// own click message had just recommended. So: wait for the first match
|
|
2215
|
-
// and SAY that there were several, instead of blocking the way out.
|
|
2216
|
-
const hits = typeof base.count === "function" ? Number(await base.count().catch(() => 1)) || 1 : 1;
|
|
2217
|
-
const loc = hits > 1 && typeof base.first === "function" ? base.first() : base;
|
|
2218
|
-
await loc.waitFor({ state: "visible", timeout });
|
|
2219
|
-
return `等待命中:${what}(≤${timeout}ms 内可见)${hits > 1 ? `\n(这个文本命中 ${hits} 个元素,等的是第一个——要精确就改用 uid 寻址)` : ""}`;
|
|
2220
|
-
}
|
|
2221
|
-
if (action === "evaluate_script") {
|
|
2222
|
-
const src = String(args.function ?? args.expression ?? "").trim();
|
|
2223
|
-
if (!src)
|
|
2224
|
-
throw new Error('缺少 expression/function 参数(JS 函数源或表达式)');
|
|
2225
|
-
traj({ step_id: stepId, event: "call", kind: "evaluate" });
|
|
2226
|
-
const value = await state.page.evaluate(evalExpression(src));
|
|
2227
|
-
return capTokens(renderEvalResult(value), "…(结果超长已截断)");
|
|
2228
|
-
}
|
|
2229
|
-
if (action === "list_pages") {
|
|
2230
|
-
const pages = context.pages();
|
|
2231
|
-
const lines = [];
|
|
2232
|
-
for (let i = 0; i < pages.length; i++) {
|
|
2233
|
-
const p = pages[i];
|
|
2234
|
-
const title = await Promise.resolve(p.title?.()).catch(() => "");
|
|
2235
|
-
lines.push(`${i}${p === state.page ? "(当前)" : ""}: ${String(p.url?.() ?? "")} ${String(title ?? "")}`.trimEnd());
|
|
2236
|
-
}
|
|
2237
|
-
return `标签页(${pages.length}):\n${lines.join("\n") || "(无)"}\n用 select_page { index } 切换。`;
|
|
2238
|
-
}
|
|
2239
|
-
if (action === "select_page") {
|
|
2240
|
-
const idx = Number(args.index);
|
|
2241
|
-
const pages = context.pages();
|
|
2242
|
-
if (!Number.isInteger(idx) || idx < 0 || idx >= pages.length) {
|
|
2243
|
-
throw new Error(`index 越界(0..${pages.length - 1})——先 list_pages`);
|
|
2244
|
-
}
|
|
2245
|
-
state.page = pages[idx];
|
|
2246
|
-
attach(state.page);
|
|
2247
|
-
await Promise.resolve(state.page.bringToFront?.()).catch(() => { });
|
|
2248
|
-
sess.currentUrl = String(state.page.url?.() ?? "");
|
|
2249
|
-
return `已切换到标签页 ${idx}:${sess.currentUrl}`;
|
|
2250
|
-
}
|
|
2251
|
-
if (action === "new_page") {
|
|
2252
|
-
// Multi-tab is an operator need, not a nicety: comparing two pages,
|
|
2253
|
-
// or reading a doc while keeping the first open, was impossible
|
|
2254
|
-
// because `open` on a live session only navigates the CURRENT tab.
|
|
2255
|
-
const p = await context.newPage();
|
|
2256
|
-
state.page = p;
|
|
2257
|
-
attach(p);
|
|
2258
|
-
const url = String(args.url ?? "").trim();
|
|
2259
|
-
const index = context.pages().indexOf(p);
|
|
2260
|
-
if (!url) {
|
|
2261
|
-
sess.currentUrl = String(p.url?.() ?? "about:blank");
|
|
2262
|
-
traj({ step_id: stepId, event: "call", kind: "new_page", url: "" });
|
|
2263
|
-
return `已新建空白标签页 ${index}(共 ${context.pages().length} 个,当前在它上面):${sess.currentUrl || "about:blank"}\n后续动作按 [uid] 寻址前请先 take_snapshot;切回去用 select_page { index }。`;
|
|
2264
|
-
}
|
|
2265
|
-
// The URL cleared the allowlist at the execute layer (that is where
|
|
2266
|
-
// the official dialog lives), exactly like navigate_page does.
|
|
2267
|
-
rememberSite(state.allowedSites, url);
|
|
2268
|
-
traj({ step_id: stepId, event: "call", kind: "new_page", url: url.slice(0, 200) });
|
|
2269
|
-
await p.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
|
2270
|
-
sess.currentUrl = p.url?.() || url;
|
|
2271
|
-
rememberSite(state.allowedSites, sess.currentUrl);
|
|
2272
|
-
return `已新建标签页 ${index} 并导航(共 ${context.pages().length} 个,当前在它上面):${url}${blockedNote(sess)}`;
|
|
2273
|
-
}
|
|
2274
|
-
if (action === "close_page") {
|
|
2275
|
-
const pages = context.pages();
|
|
2276
|
-
const raw = args.index;
|
|
2277
|
-
const idx = raw === undefined || raw === "" ? pages.indexOf(state.page) : Number(raw);
|
|
2278
|
-
if (!Number.isInteger(idx) || idx < 0 || idx >= pages.length) {
|
|
2279
|
-
throw new Error(`index 越界(0..${pages.length - 1})——先 list_pages`);
|
|
2280
|
-
}
|
|
2281
|
-
const victim = pages[idx];
|
|
2282
|
-
await Promise.resolve(victim.close?.()).catch(() => { });
|
|
2283
|
-
const rest = context.pages();
|
|
2284
|
-
const closedIndex = idx;
|
|
2285
|
-
if (!rest.length) {
|
|
2286
|
-
state.page = victim;
|
|
2287
|
-
sess.currentUrl = "";
|
|
2288
|
-
return `标签页 ${closedIndex} 已关闭,这是最后一个——浏览器仍在运行。` +
|
|
2289
|
-
`要结束整个会话用 action:"close"(它会验进程真的退出);要继续就用 new_page 再开一个。`;
|
|
2290
|
-
}
|
|
2291
|
-
state.page = rest.includes(state.page) ? state.page : rest[rest.length - 1];
|
|
2292
|
-
attach(state.page);
|
|
2293
|
-
const nextIdx = rest.indexOf(state.page);
|
|
2294
|
-
sess.currentUrl = String(state.page.url?.() ?? "");
|
|
2295
|
-
return `标签页 ${closedIndex} 已关闭,剩 ${rest.length} 个,当前在 ${nextIdx}:${sess.currentUrl}`;
|
|
2296
|
-
}
|
|
2297
|
-
if (action === "list_console_messages") {
|
|
2298
|
-
const lines = state.consoleBuf.map((m, i) => `${i + 1}. [${m.type}] ${m.text.slice(0, 300)}`);
|
|
2299
|
-
if (args.clear)
|
|
2300
|
-
state.consoleBuf.length = 0;
|
|
2301
|
-
return capTokens(`控制台消息(缓冲 ${lines.length}/${OBSERVE_BUFFER_CAP}):\n${lines.join("\n") || "(无)"}`, "…(消息超长已截断)");
|
|
2302
|
-
}
|
|
2303
|
-
if (action === "list_network_requests") {
|
|
2304
|
-
const lines = state.netBuf.map((r, i) => `${i + 1}. ${r.method} ${r.url}${r.status !== undefined ? ` → ${r.status}` : ""}${r.blocked ? " [被白名单拦截]" : r.failure ? ` [${r.failure}]` : ""}`);
|
|
2305
|
-
return capTokens(`网络请求(缓冲 ${lines.length}/${OBSERVE_BUFFER_CAP}):\n${lines.join("\n") || "(无)"}`, "…(列表超长已截断)");
|
|
2306
|
-
}
|
|
2307
|
-
if (action === "handle_dialog") {
|
|
2308
|
-
const d = state.dialogs.shift();
|
|
2309
|
-
if (!d)
|
|
2310
|
-
return "没有待处理的对话框(弹窗会在 click/submit 后出现——出现时 take_snapshot 的头部会提示,再 handle_dialog)。";
|
|
2311
|
-
const verdict = String(args.dialogAction ?? "accept").trim().toLowerCase();
|
|
2312
|
-
if (verdict === "dismiss") {
|
|
2313
|
-
await d.dismiss();
|
|
2314
|
-
}
|
|
2315
|
-
else {
|
|
2316
|
-
await d.accept(typeof args.promptText === "string" ? args.promptText : undefined);
|
|
2317
|
-
}
|
|
2318
|
-
return `已处理对话框(${d.type()}:「${d.message().slice(0, 120)}」→ ${verdict === "dismiss" ? "dismiss" : "accept"})`;
|
|
2319
|
-
}
|
|
2320
|
-
throw new Error(`playwright 引擎未知 action "${action}"`);
|
|
2321
|
-
},
|
|
2322
|
-
async close() {
|
|
2323
|
-
state.dialogs.length = 0;
|
|
2324
|
-
const errs = [];
|
|
2325
|
-
const fail = (label2) => (e) => {
|
|
2326
|
-
errs.push(`${label2}: ${String(e?.message ?? e)}`);
|
|
2327
|
-
};
|
|
2328
|
-
await Promise.resolve(context.close()).catch(fail("context"));
|
|
2329
|
-
if (browser)
|
|
2330
|
-
await Promise.resolve(browser.close()).catch(fail("browser"));
|
|
2331
|
-
// VERIFY before claiming — an agent quotes this line to the user as
|
|
2332
|
-
// "浏览器已关闭", so a swallowed failure must not read like success.
|
|
2333
|
-
let alivePages = 0;
|
|
2334
|
-
try {
|
|
2335
|
-
alivePages = context.pages().length;
|
|
2336
|
-
}
|
|
2337
|
-
catch {
|
|
2338
|
-
alivePages = 0;
|
|
2339
|
-
}
|
|
2340
|
-
const connected = browser ? Boolean(browser.isConnected?.()) : false;
|
|
2341
|
-
// The connection is NOT the process. Wait for the pid to actually
|
|
2342
|
-
// disappear; if it refuses, terminate it once and re-check. Only then
|
|
2343
|
-
// may this line say 已确认关闭 — an agent quotes it to the user as
|
|
2344
|
-
// proof the window is gone, and a lingering msedge tree is not gone.
|
|
2345
|
-
const pidWasThere = pidAlive(browserPid);
|
|
2346
|
-
const processGone = browserPid > 0 ? await waitForPidExit(browserPid) : true;
|
|
2347
|
-
const pidNote = browserPid > 0
|
|
2348
|
-
? processGone
|
|
2349
|
-
? pidWasThere
|
|
2350
|
-
? `进程 ${browserPid} 已退出`
|
|
2351
|
-
: `进程 ${browserPid} 早已不在`
|
|
2352
|
-
: `进程 ${browserPid} 仍在(已尝试终止但系统未放行)`
|
|
2353
|
-
: "未取得浏览器 pid,进程未核验";
|
|
2354
|
-
// The pid check is what makes the word 确认 mean anything. An
|
|
2355
|
-
// unverifiable close must not borrow the verified sentence, because an
|
|
2356
|
-
// agent quotes this line to the user as proof the window is gone.
|
|
2357
|
-
const verified = browserPid > 0 && processGone;
|
|
2358
|
-
const closed = errs.length === 0 && alivePages === 0 && !connected && processGone;
|
|
2359
|
-
const profile = persistentDir
|
|
2360
|
-
? `持久配置目录已保留(下次 open 复用登录态):${persistentDir}`
|
|
2361
|
-
: "临时配置目录由 playwright 自行回收";
|
|
2362
|
-
if (closed && verified)
|
|
2363
|
-
return { closed: true, text: `浏览器会话已确认关闭(${sess.label} · ${pidNote});${profile}。` };
|
|
2364
|
-
if (closed) {
|
|
2365
|
-
return {
|
|
2366
|
-
closed: false,
|
|
2367
|
-
text: `连接与窗口已释放,但进程未核验(${sess.label} · ${pidNote})——这次没拿到浏览器 pid,` +
|
|
2368
|
-
`所以这句不是"已确认关闭"。若桌面上窗口仍在请手动关闭;反复出现可换 TM_BROWSER_ENGINE=cdp-legacy 试一次。${profile}。`,
|
|
2369
|
-
};
|
|
2370
|
-
}
|
|
2371
|
-
return {
|
|
2372
|
-
closed: false,
|
|
2373
|
-
text: `警告:关闭未完全成功(${sess.label}${errs.length ? `:${errs.join("; ")}` : ""})——` +
|
|
2374
|
-
`残留标签页 ${alivePages} 个${connected ? ",浏览器进程仍处于连接状态" : ""} · ${pidNote}。` +
|
|
2375
|
-
// Three states, and the sentence must match the one we are in. A
|
|
2376
|
-
// dead pid cannot have a window on the desktop, so telling the user
|
|
2377
|
-
// to go hunt it sends them after something that is not there — and
|
|
2378
|
-
// the leftover count is OUR own stale reference (close_page leaves
|
|
2379
|
-
// state.page on the tab it closed). No pid at all is a DIFFERENT
|
|
2380
|
-
// fact: nothing was verified, so the window may genuinely remain.
|
|
2381
|
-
(browserPid > 0 && processGone
|
|
2382
|
-
? `进程已确认不在(pid ${browserPid} 已退出),所以桌面上不该还有它的窗口——那个"残留标签页"是我自己留着的路由引用,不是窗口,你不用去找。${profile}。`
|
|
2383
|
-
: browserPid > 0
|
|
2384
|
-
? `窗口很可能仍在前台(pid ${browserPid} 还在),请用户手动关闭该浏览器窗口。${profile}。`
|
|
2385
|
-
: `这次没拿到浏览器 pid,进程无法核验——若桌面上窗口仍在请手动关闭。${profile}。`),
|
|
2386
|
-
};
|
|
2387
|
-
},
|
|
2388
|
-
};
|
|
2389
|
-
return sess;
|
|
2390
|
-
}
|
|
2391
|
-
// ---------- engine-neutral session lifecycle ----------
|
|
2392
|
-
/** Idle reaper, PER LEASE. The pre-v1.5.13 layer had NO cleanup path other
|
|
2393
|
-
* than the agent remembering to call `close` — and an agent that believes it
|
|
2394
|
-
* closed the window (issue #3) leaves the user staring at a Chromium window
|
|
2395
|
-
* nobody owns. With one browser per caller the timer belongs to the lease:
|
|
2396
|
-
* one agent going quiet must not keep another's window alive, and a reap must
|
|
2397
|
-
* never touch a browser somebody is still driving. */
|
|
2398
|
-
function armIdle(lease) {
|
|
2399
|
-
clearIdle(lease.id);
|
|
2400
|
-
if (!idleCloseMs)
|
|
2401
|
-
return;
|
|
2402
|
-
const t = setTimeout(() => {
|
|
2403
|
-
idleTimers.delete(lease.id);
|
|
2404
|
-
if (!leases.has(lease.id))
|
|
2405
|
-
return;
|
|
2406
|
-
traj({ step_id: "browser", event: "idle_close", idle_ms: idleCloseMs, id: lease.id, agent: lease.agent });
|
|
2407
|
-
void closeLease(lease)
|
|
2408
|
-
.then((r) => notify?.(`tm_browser ${lease.id}(${lease.agent || "agent"} 的浏览器)空闲 ${Math.round(idleCloseMs / 1000)}s,已自动关闭${r.closed ? "(窗口应已消失)" : "——但窗口可能仍需手动关闭"}`))
|
|
2409
|
-
.catch(() => { });
|
|
2410
|
-
}, idleCloseMs);
|
|
2411
|
-
t.unref?.();
|
|
2412
|
-
idleTimers.set(lease.id, t);
|
|
2413
|
-
}
|
|
2414
|
-
/** The caller's browser, launched on first use. `open` on an existing lease
|
|
2415
|
-
* navigates that window instead of spawning a second one. */
|
|
2416
|
-
async function ensureLease(headless, caller) {
|
|
2417
|
-
const mine = ownedBy(caller.owner);
|
|
2418
|
-
if (mine.length)
|
|
2419
|
-
return mine[0];
|
|
2420
|
-
const sel = await selection();
|
|
2421
|
-
const approved = approvedFor(caller.owner);
|
|
2422
|
-
const session = sel.kind === "playwright" && sel.pw
|
|
2423
|
-
? await openPlaywrightSession(sel.pw, headless, approved)
|
|
2424
|
-
: await openLegacySession(headless, approved);
|
|
2425
|
-
const lease = {
|
|
2426
|
-
id: `b${++leaseSeq}`,
|
|
2427
|
-
owner: caller.owner,
|
|
2428
|
-
agent: caller.agent,
|
|
2429
|
-
session,
|
|
2430
|
-
at: Date.now(),
|
|
2431
|
-
lastActivity: Date.now(),
|
|
2432
|
-
};
|
|
2433
|
-
leases.set(lease.id, lease);
|
|
2434
|
-
traj({ step_id: "browser", event: "lease", id: lease.id, agent: lease.agent, live: leases.size });
|
|
2435
|
-
return lease;
|
|
2436
|
-
}
|
|
2437
|
-
async function closeLease(lease) {
|
|
2438
|
-
if (!lease)
|
|
2439
|
-
return { text: "没有打开的浏览器会话。", closed: true };
|
|
2440
|
-
leases.delete(lease.id);
|
|
2441
|
-
clearIdle(lease.id);
|
|
2442
|
-
return lease.session.close();
|
|
2443
|
-
}
|
|
2444
|
-
/** Move any screenshot pixels act() produced out of the session and onto
|
|
2445
|
-
* the ToolResult. Drained per call so a stale attachment can never ride
|
|
2446
|
-
* along on the next action. */
|
|
2447
|
-
function withAttachments(res, sess) {
|
|
2448
|
-
const atts = sess?.attachments ?? [];
|
|
2449
|
-
if (sess)
|
|
2450
|
-
sess.attachments = [];
|
|
2451
|
-
if (!atts.length)
|
|
2452
|
-
return res;
|
|
2453
|
-
const base = typeof res === "string" ? { output: res } : res;
|
|
2454
|
-
return { ...base, attachments: atts };
|
|
2455
|
-
}
|
|
2456
|
-
const ALL_ACTIONS = new Set(ALL_BROWSER_ACTIONS);
|
|
2457
|
-
// The menu IS the table the gate below reads. A hand-written copy drifted the
|
|
2458
|
-
// day `new_page` landed: an agent that mistyped a verb was shown a list that
|
|
2459
|
-
// did not contain the capability it wanted.
|
|
2460
|
-
const ACTION_MENU = BROWSER_ACTION_MENU;
|
|
2461
|
-
const execute = async (rawArgs, ctx) => {
|
|
2462
|
-
// Set once a lease is resolved, so the catch below knows WHICH browser died
|
|
2463
|
-
// — with one browser per caller, dropping the wrong lease would kill an
|
|
2464
|
-
// agent that was working fine.
|
|
2465
|
-
let activeLease = null;
|
|
2466
|
-
try {
|
|
2467
|
-
const args = rawArgs ?? {};
|
|
2468
|
-
const action = String(args.action ?? "").trim();
|
|
2469
|
-
const url = String(args.url ?? "").trim();
|
|
2470
|
-
// Whose browser this is. The host hands every tool ctx a sessionID, and
|
|
2471
|
-
// a lease is keyed by it; without one (an older host, a test harness) all
|
|
2472
|
-
// callers share the single anonymous bucket, which is the old behaviour.
|
|
2473
|
-
const caller = callerOf(ctx);
|
|
2474
|
-
// The profile knob is checked BEFORE any engine runs: a refused value must
|
|
2475
|
-
// not spawn a browser that then discovers the problem.
|
|
2476
|
-
{
|
|
2477
|
-
const probe = classifyProfileDir(env);
|
|
2478
|
-
if (probe.refusal)
|
|
2479
|
-
return toToolResult(tmError(tool, "args", probe.refusal));
|
|
2480
|
-
}
|
|
2481
|
-
// Set when this call proceeded on a DIALOG approval rather than the
|
|
2482
|
-
// static allowlist; appended to whichever reply the action returns.
|
|
2483
|
-
let grantNote = "";
|
|
2484
|
-
// allowlist FIRST — an out-of-allowlist URL only proceeds after the
|
|
2485
|
-
// OFFICIAL dialog approves it (then the host passes the network layer
|
|
2486
|
-
// too); hard red lines (scheme / env-file) reject with no dialog.
|
|
2487
|
-
// NOTE: this gate runs BEFORE engine selection — a blocked target
|
|
2488
|
-
// never touches playwright nor spawns anything (§6o invariant).
|
|
2489
|
-
// new_page carries a URL too — it must clear the SAME allowlist gate, or
|
|
2490
|
-
// "open a second tab" becomes a way around the official dialog.
|
|
2491
|
-
if ((action === "open" || action === "navigate" || action === "navigate_page" || action === "new_page") && url) {
|
|
2492
|
-
// Search-results pages are tm_search's job: one call returns a deduped,
|
|
2493
|
-
// RRF-fused hit list, while a browser SERP grab costs a round trip and a
|
|
2494
|
-
// snapshot budget each time. A live session burned ~30 of them. The
|
|
2495
|
-
// first few still pass (bing's HTML is sometimes an anti-bot shell and a
|
|
2496
|
-
// real browser is the only way through), then the navigation is refused.
|
|
2497
|
-
const serp = serpLoop.observe(url);
|
|
2498
|
-
if (serp) {
|
|
2499
|
-
pipelines.store.appendTrajectory({
|
|
2500
|
-
tool,
|
|
2501
|
-
step_id: pipelines.nextStepId(),
|
|
2502
|
-
event: serp.blocked ? "serp_refused" : "serp_nav",
|
|
2503
|
-
engine: serp.target.engine,
|
|
2504
|
-
repeats: serp.count,
|
|
2505
|
-
});
|
|
2506
|
-
if (serp.blocked) {
|
|
2507
|
-
return toToolResult(tmError(tool, "permission", serpRefusal(serp)));
|
|
2508
|
-
}
|
|
2509
|
-
}
|
|
2510
|
-
const verdict = checkWebUrl(url, allowlist);
|
|
2511
|
-
if (!verdict.ok) {
|
|
2512
|
-
if (!verdict.askable || !verdict.url) {
|
|
2513
|
-
return toToolResult(tmError(tool, "permission", verdict.message));
|
|
2514
|
-
}
|
|
2515
|
-
const res = await askUserForTargetDetailed(ctx, {
|
|
2516
|
-
permission: tool,
|
|
2517
|
-
patterns: [verdict.url.toString()],
|
|
2518
|
-
metadata: { tool, url: url.slice(0, 200) },
|
|
2519
|
-
});
|
|
2520
|
-
if (res.outcome !== "approved") {
|
|
2521
|
-
return toToolResult(tmError(tool, "permission", verdict.message + " " + askRefusalNote(res.outcome)));
|
|
2522
|
-
}
|
|
2523
|
-
approvedFor(caller.owner).add(verdict.url.hostname);
|
|
2524
|
-
// The approval is recorded in the REPLY, not only the trajectory: an
|
|
2525
|
-
// agent that cannot tell a saved "always" from a fresh click reports
|
|
2526
|
-
// "no dialog appeared" as evidence about the allowlist — measured live,
|
|
2527
|
-
// that is exactly what a researcher wrote into its deliverable.
|
|
2528
|
-
grantNote = askGrantNote(res);
|
|
2529
|
-
traj({
|
|
2530
|
-
step_id: "browser",
|
|
2531
|
-
event: res.autoGranted ? "silent_grant" : "dialog_approved",
|
|
2532
|
-
host: verdict.url.hostname,
|
|
2533
|
-
answered_ms: res.answeredInMs,
|
|
2534
|
-
});
|
|
2535
|
-
}
|
|
2536
|
-
}
|
|
2537
|
-
// The remedy for "our gate blanked this page". A blocked asset host used
|
|
2538
|
-
// to be a dead end: the only move was an env edit plus a restart, which no
|
|
2539
|
-
// agent can do mid-task, so it reported the site as empty instead. This
|
|
2540
|
-
// routes the SAME official dialog at ONE host, for ONE owner, for the
|
|
2541
|
-
// length of the session — and it cannot widen anything: a host already on
|
|
2542
|
-
// the static allowlist is refused (asking would train the user to approve
|
|
2543
|
-
// what is already allowed), a red-line host is refused with no dialog, and
|
|
2544
|
-
// the pattern handed to the dialog is the exact hostname, never a glob.
|
|
2545
|
-
if (action === "allow_host") {
|
|
2546
|
-
const host = normalizeApprovalHost(args.host);
|
|
2547
|
-
if (!host) {
|
|
2548
|
-
return toToolResult(tmError(tool, "args", '缺少或非法的 host 参数——要形如 "bkssl.bdimg.com" 的单个域名(不接受通配、URL、路径或端口)'));
|
|
2549
|
-
}
|
|
2550
|
-
const verdict = checkWebUrl(`https://${host}/`, allowlist);
|
|
2551
|
-
if (verdict.ok) {
|
|
2552
|
-
return toToolResult(`不用批准:${host} 本来就在静态白名单里,它的子资源不会被拦。这一页的空白另有原因——看 take_snapshot 的拦截说明(被拦的是别的域名)或用 read 拿原文。`);
|
|
2553
|
-
}
|
|
2554
|
-
if (!verdict.askable || !verdict.url) {
|
|
2555
|
-
return toToolResult(tmError(tool, "permission", `拒绝放行 ${host}:${verdict.message}`));
|
|
2556
|
-
}
|
|
2557
|
-
const outcome = await askUserForTarget(ctx, {
|
|
2558
|
-
permission: tool,
|
|
2559
|
-
patterns: [host],
|
|
2560
|
-
metadata: { tool, host, reason: "被拦的跨域脚本域名,放行后需重新导航" },
|
|
2561
|
-
});
|
|
2562
|
-
if (outcome !== "approved") {
|
|
2563
|
-
return toToolResult(tmError(tool, "permission", `未放行 ${host}。` + askRefusalNote(outcome)));
|
|
2564
|
-
}
|
|
2565
|
-
approvedFor(caller.owner).add(host);
|
|
2566
|
-
traj({ step_id: "browser", event: "host_approved", host, agent: caller.agent });
|
|
2567
|
-
const mine = ownedBy(caller.owner)[0];
|
|
2568
|
-
return toToolResult(`${mine ? `[${mine.id}] ` : ""}已按用户批准临时放行 ${host}(只对这个浏览器、本次会话,不改任何配置文件)。` +
|
|
2569
|
-
`现在重新 navigate 一次同一页——门禁在请求时判定,已经加载完的页面不会自己把脚本补回来。`);
|
|
2570
|
-
}
|
|
2571
|
-
if (action === "open") {
|
|
2572
|
-
if (!url)
|
|
2573
|
-
return toToolResult(tmError(tool, "args", "缺少 url 参数"));
|
|
2574
|
-
const existing = ownedBy(caller.owner)[0];
|
|
2575
|
-
if (existing) {
|
|
2576
|
-
// already open: navigate instead of spawning a second browser.
|
|
2577
|
-
// Report the mode the RUNNING instance is actually in — the old
|
|
2578
|
-
// code echoed the REQUESTED mode, so a sticky session could be
|
|
2579
|
-
// described as headful while it was a headless leftover.
|
|
2580
|
-
// Tracked as the active lease: a browser that dies HERE must be
|
|
2581
|
-
// dropped too, or the next open hands back the same corpse.
|
|
2582
|
-
activeLease = existing;
|
|
2583
|
-
const out = await existing.session.act("navigate", { url }, "browser");
|
|
2584
|
-
armIdle(existing);
|
|
2585
|
-
return withAttachments(toToolResult(`[${existing.id}] 复用你自己的浏览器(${existing.session.headless ? "无头" : "有头窗口"} · ${existing.session.label})。${out}${grantNote}`), existing.session);
|
|
2586
|
-
}
|
|
2587
|
-
// Mode is an OPERATOR decision (TM_BROWSER_HEADLESS), never a model
|
|
2588
|
-
// arg: a model that once passed headless:"false" (string → truthy
|
|
2589
|
-
// under Boolean()) pinned the whole process to headless and every
|
|
2590
|
-
// anti-bot gate in the run failed.
|
|
2591
|
-
const headless = resolveHeadless(env);
|
|
2592
|
-
const lease = await ensureLease(headless, caller);
|
|
2593
|
-
activeLease = lease;
|
|
2594
|
-
const s = lease.session;
|
|
2595
|
-
const out = await s.act("navigate", { url }, "browser");
|
|
2596
|
-
traj({ step_id: "browser", event: "open", headless, label: s.label, id: lease.id, agent: lease.agent });
|
|
2597
|
-
const mode = s.headless ? "无头" : "有头窗口";
|
|
2598
|
-
// From the SESSION, never from the environment: this line used to re-read
|
|
2599
|
-
// TM_BROWSER_USER_DATA_DIR at reply time, so a session that had degraded
|
|
2600
|
-
// to cdp-legacy (temp profile) announced 持久登录配置.
|
|
2601
|
-
const profile = s.persistentProfile ? "持久登录配置" : "隔离临时配置";
|
|
2602
|
-
const note = lastSel && preference !== "cdp-legacy" && lastSel.kind === "cdp-legacy"
|
|
2603
|
-
? `\n(引擎:cdp-legacy 降级——${lastSel.reason ?? ""};完整 16 动作需 npm install playwright-core + node≥20)`
|
|
2604
|
-
: "";
|
|
2605
|
-
const guard = subPolicy === "off"
|
|
2606
|
-
? `\n(子资源策略=off:跨域图片/脚本一律拦截,页面可能显示不全——TM_BROWSER_SUBRESOURCE=same-site 可恢复)`
|
|
2607
|
-
: "";
|
|
2608
|
-
const others = liveLeases().length - 1;
|
|
2609
|
-
// The close duty belongs in the reply that OPENS the window, not only in
|
|
2610
|
-
// the tool description: a long-lived host never re-reads a description
|
|
2611
|
-
// (the same finding that moved the date onto the search header), and the
|
|
2612
|
-
// agent's attention is here — the moment it stops needing the page is the
|
|
2613
|
-
// moment it forgets the window exists. When the reaper is off, say so
|
|
2614
|
-
// rather than promising a cleanup that will not come.
|
|
2615
|
-
const duty = idleCloseMs
|
|
2616
|
-
? `\n用完必须 action:"close"(窗口用户看得见;空闲 ${Math.round(idleCloseMs / 1000)}s 我会替你关掉并提示他)。`
|
|
2617
|
-
: `\n用完必须 action:"close"——TM_BROWSER_IDLE_MS=0,没有空闲回收,你不关它就一直开着。`;
|
|
2618
|
-
armIdle(lease);
|
|
2619
|
-
return withAttachments(toToolResult(`[${lease.id}] 浏览器已启动(${mode},${profile} · ${s.label})。${out}${note}${guard}` +
|
|
2620
|
-
`\n这个窗口的 id 是 ${lease.id},归你(${lease.agent || "本会话"}):后续动作带 id:"${lease.id}"。` +
|
|
2621
|
-
duty +
|
|
2622
|
-
(others > 0
|
|
2623
|
-
? `现在共有 ${others + 1} 个浏览器在跑(别的 agent 各有各的窗口),省略 id 会被拒绝。`
|
|
2624
|
-
: "") +
|
|
2625
|
-
grantNote), s);
|
|
2626
|
-
}
|
|
2627
|
-
if (action === "navigate" || action === "navigate_page") {
|
|
2628
|
-
if (!url)
|
|
2629
|
-
return toToolResult(tmError(tool, "args", "缺少 url 参数"));
|
|
2630
|
-
const got = resolveLease(args, caller);
|
|
2631
|
-
if ("error" in got)
|
|
2632
|
-
return toToolResult(tmError(tool, "args", got.error));
|
|
2633
|
-
activeLease = got.lease;
|
|
2634
|
-
const out = await got.lease.session.act(action, { url }, "browser");
|
|
2635
|
-
armIdle(got.lease);
|
|
2636
|
-
return withAttachments(toToolResult(`[${got.lease.id}] ${out}${grantNote}`), got.lease.session);
|
|
2637
|
-
}
|
|
2638
|
-
if (action === "close") {
|
|
2639
|
-
// `id:"all"` closes every browser the CALLER owns — never anybody
|
|
2640
|
-
// else's, which is the one thing a shared kill switch would get wrong.
|
|
2641
|
-
const mine = ownedBy(caller.owner);
|
|
2642
|
-
if (String(args.id ?? "").trim().toLowerCase() === "all") {
|
|
2643
|
-
if (!mine.length)
|
|
2644
|
-
return toToolResult(tmError(tool, "args", "你没有打开的浏览器。"));
|
|
2645
|
-
traj({ step_id: "browser", event: "close", id: "all", count: mine.length, agent: caller.agent });
|
|
2646
|
-
const parts = [];
|
|
2647
|
-
for (const l of mine)
|
|
2648
|
-
parts.push(`[${l.id}] ${(await closeLease(l)).text}`);
|
|
2649
|
-
return toToolResult(parts.join("\n"));
|
|
2650
|
-
}
|
|
2651
|
-
const got = resolveLease(args, caller);
|
|
2652
|
-
if ("error" in got)
|
|
2653
|
-
return toToolResult(tmError(tool, "args", got.error));
|
|
2654
|
-
traj({ step_id: "browser", event: "close", id: got.lease.id, agent: caller.agent });
|
|
2655
|
-
const r = await closeLease(got.lease);
|
|
2656
|
-
return toToolResult(`[${got.lease.id}] ${r.text}`);
|
|
2657
|
-
}
|
|
2658
|
-
if (!ALL_ACTIONS.has(action)) {
|
|
2659
|
-
return toToolResult(tmError(tool, "args", `未知 action "${String(action).slice(0, 30)}"——可用: ${ACTION_MENU}(快照优先:先 take_snapshot,按 [uid=…] 寻址)`));
|
|
2660
|
-
}
|
|
2661
|
-
const got = resolveLease(args, caller);
|
|
2662
|
-
if ("error" in got)
|
|
2663
|
-
return toToolResult(tmError(tool, "args", got.error));
|
|
2664
|
-
const lease = got.lease;
|
|
2665
|
-
// Bound locally so the dispatch below reads the way it did when the whole
|
|
2666
|
-
// process had one browser — except `session` now means "the CALLER's
|
|
2667
|
-
// browser", never "the browser".
|
|
2668
|
-
const session = lease.session;
|
|
2669
|
-
activeLease = lease;
|
|
2670
|
-
armIdle(lease);
|
|
2671
|
-
// M1: upload_file SOURCE containment runs at the execute layer — the
|
|
2672
|
-
// engine act() has no host ctx. Out-of-P2 paths and R6 env files are
|
|
2673
|
-
// refused before setInputFiles can move any bytes.
|
|
2674
|
-
if (action === "upload_file") {
|
|
2675
|
-
const refusal = uploadExfilGuard(args, ctx);
|
|
2676
|
-
if (refusal) {
|
|
2677
|
-
traj({ step_id: "browser", event: "upload_refused", reason: refusal.slice(0, 200) });
|
|
2678
|
-
return toToolResult(tmError(tool, "permission", refusal));
|
|
2679
|
-
}
|
|
2680
|
-
}
|
|
2681
|
-
// M5 (v1.5.14): evaluate_script's consent + result redaction run at the
|
|
2682
|
-
// execute layer for BOTH engines — same reason upload_file's source
|
|
2683
|
-
// check does: the engine act() has no host ctx, and one choke point
|
|
2684
|
-
// cannot drift from the other.
|
|
2685
|
-
if (action === "evaluate_script" && needsEvalConsent(evalAskPolicy, session.evalApproved)) {
|
|
2686
|
-
const target = hostOnly(session.currentUrl);
|
|
2687
|
-
// An error page or a blank tab has no host to consent to. Asking the
|
|
2688
|
-
// official dialog about `evaluate_script:` with an EMPTY pattern gives
|
|
2689
|
-
// the user a prompt they cannot judge, and the refusal then reads
|
|
2690
|
-
// "未获批准(目标站点 )" — seen live on a chrome-error:// tab.
|
|
2691
|
-
if (!/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(target)) {
|
|
2692
|
-
const where = String(session.currentUrl ?? "").slice(0, 80) || "空白页";
|
|
2693
|
-
traj({ step_id: "browser", event: "eval_refused_no_host", where });
|
|
2694
|
-
return toToolResult(tmError(tool, "permission", `evaluate_script 被拒绝:当前页面不是可识别的 http(s) 站点(${where}),没有可批准的主机。` +
|
|
2695
|
-
`先 open/navigate 到一个真实页面再执行 JS——对着错误页要权限,用户无法判断该不该批。`));
|
|
2696
|
-
}
|
|
2697
|
-
const outcome = await askUserForTarget(ctx, {
|
|
2698
|
-
permission: tool,
|
|
2699
|
-
patterns: [`evaluate_script:${target}`],
|
|
2700
|
-
metadata: {
|
|
2701
|
-
tool,
|
|
2702
|
-
action: "evaluate_script",
|
|
2703
|
-
host: target,
|
|
2704
|
-
source: String(args.function ?? args.expression ?? "").slice(0, 160),
|
|
2705
|
-
},
|
|
2706
|
-
});
|
|
2707
|
-
traj({ step_id: "browser", event: "eval_consent", host: target, verdict: outcome });
|
|
2708
|
-
if (outcome !== "approved") {
|
|
2709
|
-
return toToolResult(tmError(tool, "permission", (outcome === "unavailable"
|
|
2710
|
-
? `evaluate_script 需要官方确认窗授权,本宿主没有 ask 桥——已拒绝执行。TM_BROWSER_ASK_EVAL=off 可关掉这道确认(不建议:这个动词能在你已登录的浏览器里读任意页面数据)。`
|
|
2711
|
-
: `evaluate_script 未获批准(目标站点 ${target}),已拒绝执行。改用 take_snapshot/read 的受治理读取,或让用户单独批准。`) +
|
|
2712
|
-
" " +
|
|
2713
|
-
askRefusalNote(outcome)));
|
|
2714
|
-
}
|
|
2715
|
-
session.evalApproved = true;
|
|
2716
|
-
}
|
|
2717
|
-
const stepId = pipelines.nextStepId();
|
|
2718
|
-
const out = await session.act(action, args, stepId);
|
|
2719
|
-
if (action !== "evaluate_script") {
|
|
2720
|
-
return withAttachments(toToolResult(`[${lease.id}] ${out}${grantNote}`), session);
|
|
2721
|
-
}
|
|
2722
|
-
// The value is masked BEFORE it can reach the context window, the run
|
|
2723
|
-
// store or the trajectory; the agent is told a mask happened (a silent
|
|
2724
|
-
// blank would read as "the page has no token").
|
|
2725
|
-
const red = redactEvalResult(out);
|
|
2726
|
-
if (red.masked.length) {
|
|
2727
|
-
traj({ step_id: "browser", event: "eval_redacted", kinds: red.masked.join(",") });
|
|
2728
|
-
return withAttachments(toToolResult(`[${lease.id}] ${red.text}\n注意:结果里有 ${red.masked.join("、")} 被识别为密钥形状并已脱敏——不是页面没有这些值,是插件不把它们送进上下文。需要它们请让用户自己在浏览器里看。`), session);
|
|
2729
|
-
}
|
|
2730
|
-
return withAttachments(toToolResult(`[${lease.id}] ${red.text}`), session);
|
|
2731
|
-
}
|
|
2732
|
-
catch (err) {
|
|
2733
|
-
const e = err;
|
|
2734
|
-
const msg = String(e?.message ?? err ?? "browser 操作失败");
|
|
2735
|
-
// A browser the user closed — or a launch that handed its request to an
|
|
2736
|
-
// already-running Edge and exited — leaves a cached session whose every
|
|
2737
|
-
// action throws the SAME line. Live session: three identical failures,
|
|
2738
|
-
// then a fourth, with no way out except our own bookkeeping. Drop the
|
|
2739
|
-
// corpse, say that we did, and name the one move that works. With one
|
|
2740
|
-
// browser per caller, only THAT lease is dropped: another agent's window
|
|
2741
|
-
// is not ours to declare dead.
|
|
2742
|
-
if (/has been closed|Target closed|Browser closed|browser has been closed/i.test(msg)) {
|
|
2743
|
-
const dead = activeLease;
|
|
2744
|
-
const leaseId = dead?.id ?? "";
|
|
2745
|
-
// That one string covers two very different facts — a TAB the user closed
|
|
2746
|
-
// and a BROWSER that died. Only the second may cost the lease: measured
|
|
2747
|
-
// live, closing the last tab threw this line while the window was still
|
|
2748
|
-
// up, the lease got dropped, `close` then reported "no browser open", and
|
|
2749
|
-
// the user was left holding a browser no agent could address.
|
|
2750
|
-
const alive = dead ? dead.session.alive?.() !== false : false;
|
|
2751
|
-
if (dead && !alive) {
|
|
2752
|
-
leases.delete(dead.id);
|
|
2753
|
-
clearIdle(dead.id);
|
|
2754
|
-
}
|
|
2755
|
-
traj({
|
|
2756
|
-
step_id: "browser",
|
|
2757
|
-
event: alive ? "page_dead_browser_alive" : "session_dead",
|
|
2758
|
-
cleared: Boolean(dead && !alive),
|
|
2759
|
-
id: leaseId,
|
|
2760
|
-
reason: msg.slice(0, 160),
|
|
2761
|
-
});
|
|
2762
|
-
return toToolResult(tmError(tool, "execute", alive
|
|
2763
|
-
? `${msg.slice(0, 200)}\n但浏览器本身还连着(${leaseId} 我保留了,你的 id 不变)——死的是**这一个页面/标签**,不是实例。` +
|
|
2764
|
-
`下一步:list_pages 看还剩几个标签,或 new_page 在同一个窗口开一个;要结束整个会话用 close。`
|
|
2765
|
-
: `${msg.slice(0, 200)}\n浏览器实例已失效${dead ? `(${leaseId},${dead.agent || "本会话"} 的窗口):我已丢弃它,下一次 action:"open" 会真的重启一个新实例` : ",当前没有可复用的会话"}。` +
|
|
2766
|
-
`连着两次都这样,通常是本机已有同品牌浏览器在跑、新进程把请求移交给旧实例后退掉了——请用户关掉那个窗口再 open,或设 TM_BROWSER_ENGINE=cdp-legacy 换一条传输。` +
|
|
2767
|
-
`不要第三次重复同一个动作。`));
|
|
2768
|
-
}
|
|
2769
|
-
return toToolResult(tmError(tool, "execute", msg));
|
|
2770
|
-
}
|
|
2771
|
-
};
|
|
2772
|
-
// The description is MEASURED cost: on v2 with `codemode: false` every token here
|
|
2773
|
-
// rides every request the role makes, so this text is a budget line, not a
|
|
2774
|
-
// changelog. What stays is what the model cannot recover from a tool result — the
|
|
2775
|
-
// flow, the ownership rule, and the three close verdicts. What left is the history
|
|
2776
|
-
// and the reasoning behind each rule (both live in AGENTS.md and CHANGELOG.md,
|
|
2777
|
-
// where a future reader looks and where it costs nothing per request).
|
|
2778
|
-
const DESCRIPTION = `Interactive governed browser: drives the user's own Chromium-family browser, headful. playwright-core primary (Node>=20), auto-degrades to the zero-dep CDP pipe (snapshot verbs unavailable). Flow: open → take_snapshot → act by [uid] → observe again.
|
|
2779
|
-
- Verbs: navigate_page · take_snapshot · click · fill · hover · drag · press_key · select_page · new_page · close_page · list_pages · upload_file · wait_for · evaluate_script · list_console_messages · list_network_requests · take_screenshot · handle_dialog · open · navigate · read · screenshot · close · allow_host.
|
|
2780
|
-
- ONE BROWSER PER AGENT: open returns an id ("b1"); that window, its uid numbering and its approvals are yours. Pass id once more than one browser is live; another agent's id is refused with its owner named (a shared window means your take_snapshot renumbers the uids they hold). close { id:"all" } closes only yours.
|
|
2781
|
-
- Multi-tab: new_page {url?} opens and makes current (same gate as navigate), select_page/close_page by index, closing the LAST tab does NOT close the browser and says so.
|
|
2782
|
-
- Act only on uids from the LATEST snapshot; one action then one observation; dialogs fold into the same round; 3000 ms action budget; never wait for networkidle. take_screenshot writes the PNG to the run store and attaches a JPEG only with image:true (pixels cost context). evaluate_script needs one official dialog per session and its result is scanned so token/cookie shapes never enter the context.
|
|
2783
|
-
- Lifecycle: headful unless the operator sets TM_BROWSER_HEADLESS; idle sessions self-close (TM_BROWSER_IDLE_MS) and the user is told. ALWAYS action:"close" when done and quote the tool's own verdict — 已确认关闭 / 进程未核验 / 警告:关闭未完全成功 — instead of asserting the window is gone.
|
|
2784
|
-
- Network: navigation must clear the domain allowlist (out-of-allowlist asks through the official dialog BEFORE any spawn); subresources follow TM_BROWSER_SUBRESOURCE (default same-site: images/fonts/css load, scripts only for a site this session opened). A "N 个子资源请求被拦截" note means OUR gate trimmed the page, not that the site is empty. env-file URLs and non-http(s) schemes always reject.
|
|
2785
|
-
- A blank page gets a reason: the reply names the blocked host and offers allow_host { host } (one bare domain, this browser, this session, nothing written to config — then re-navigate) or TM_WEBFETCH_ALLOWED_DOMAINS + restart. A human-verification wall (安全验证 / Cloudflare / access denied) is reported as a wall: the move is another source, not another retry.
|
|
2786
|
-
- A dead instance is never retried: on "has been closed" the lease is dropped, the reply says the next open rebuilds, and a third identical open means a competing window or TM_BROWSER_ENGINE=cdp-legacy. No browser installed → structured error; fall back to tm_webfetch / MCP. Granted to team + researcher, browser-only for the tester.`;
|
|
2787
|
-
return {
|
|
2788
|
-
description: DESCRIPTION,
|
|
2789
|
-
args: deps.args ?? {
|
|
2790
|
-
action: { descriptor: "action: take_snapshot|click|fill|navigate_page|... (18 playwright verbs + open/navigate/read/screenshot/close)" },
|
|
2791
|
-
id: { descriptor: 'id: which browser — the id open gave you ("b1"). One browser per agent: required once more than one is live, and it must be YOURS (another agent\'s id is refused). close {id:"all"} closes all of yours' },
|
|
2792
|
-
url: { descriptor: "url: string (open/navigate/navigate_page, allowlisted https)" },
|
|
2793
|
-
image: { descriptor: "image: true (take_screenshot only — attach the PNG pixels to this result; default is path-only)" },
|
|
2794
|
-
uid: { descriptor: "uid: snapshot [uid=eN] token (click/fill/hover/drag/upload_file/wait_for)" },
|
|
2795
|
-
selector: { descriptor: "selector: CSS/text locator escape hatch (only when a snapshot cannot express the node)" },
|
|
2796
|
-
targetUid: { descriptor: "targetUid: drag destination uid" },
|
|
2797
|
-
targetSelector: { descriptor: "targetSelector: drag destination selector" },
|
|
2798
|
-
text: { descriptor: "text: fill value / wait_for visible text" },
|
|
2799
|
-
key: { descriptor: "key: press_key chord, e.g. Enter|Control+A" },
|
|
2800
|
-
function: { descriptor: "function: JS function source for evaluate_script" },
|
|
2801
|
-
filePath: { descriptor: "filePath: local file path(s) for upload_file" },
|
|
2802
|
-
index: { descriptor: "index: select_page target from list_pages" },
|
|
2803
|
-
timeoutMs: { descriptor: "timeoutMs: wait_for budget (default 3000)" },
|
|
2804
|
-
fullPage: { descriptor: "fullPage: take_screenshot full page (default viewport only)" },
|
|
2805
|
-
dialogAction: { descriptor: "dialogAction: accept|dismiss for handle_dialog (default accept)" },
|
|
2806
|
-
promptText: { descriptor: "promptText: prompt-dialog reply for handle_dialog accept" },
|
|
2807
|
-
clear: { descriptor: "clear: drain the console buffer after list_console_messages" },
|
|
2808
|
-
},
|
|
2809
|
-
execute,
|
|
2810
|
-
dispose: async () => {
|
|
2811
|
-
for (const t of idleTimers.values())
|
|
2812
|
-
clearTimeout(t);
|
|
2813
|
-
idleTimers.clear();
|
|
2814
|
-
// AWAITED: the host's dispose hook is Promise-returning, and tearing
|
|
2815
|
-
// down while a close was still in flight left the window on screen.
|
|
2816
|
-
// EVERY lease, not just one caller's: plugin unload owns all of them.
|
|
2817
|
-
for (const l of liveLeases()) {
|
|
2818
|
-
await closeLease(l).catch(() => { });
|
|
2819
|
-
}
|
|
2820
|
-
},
|
|
2821
|
-
engineInfo: () => ({
|
|
2822
|
-
kind: lastSel?.kind ?? null,
|
|
2823
|
-
reason: lastSel?.reason ?? null,
|
|
2824
|
-
}),
|
|
2825
|
-
leases: () => {
|
|
2826
|
-
const nowMs = Date.now();
|
|
2827
|
-
return liveLeases().map((l) => ({
|
|
2828
|
-
id: l.id,
|
|
2829
|
-
owner: l.owner,
|
|
2830
|
-
agent: l.agent,
|
|
2831
|
-
idleMs: Math.max(0, nowMs - l.lastActivity),
|
|
2832
|
-
}));
|
|
2833
|
-
},
|
|
2834
|
-
};
|
|
2835
|
-
}
|
|
2836
|
-
//# sourceMappingURL=browser.js.map
|