@kingsoftcloud/agentkit-personal-assistant 0.0.0-stage → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +7 -0
  3. package/README.md +38 -2
  4. package/cordis.patch.yml +6 -0
  5. package/dist/client.js +6 -0
  6. package/package.json +83 -4
  7. package/personal-assistant.plugin.json +31 -0
  8. package/personal-assistant.plugin.schema.json +43 -0
  9. package/runtime/assistant-runtime.mjs +317 -0
  10. package/runtime/check-manifest.mjs +21 -0
  11. package/runtime/cloud-browser-cdp.mjs +313 -0
  12. package/runtime/cloud-browser.mjs +972 -0
  13. package/runtime/cloud-execution.mjs +320 -0
  14. package/runtime/cloud-session-channel.mjs +45 -0
  15. package/runtime/computer-agentd.mjs +488 -0
  16. package/runtime/desktop-clipboard-limit.mjs +2 -0
  17. package/runtime/desktop-launcher.mjs +106 -0
  18. package/runtime/desktop-permit.mjs +42 -0
  19. package/runtime/desktop-session.mjs +840 -0
  20. package/runtime/desktop-ws.mjs +371 -0
  21. package/runtime/prepare-dsh-profile.mjs +17 -0
  22. package/runtime/welcome-server.mjs +144 -0
  23. package/scripts/assistant-runtime-launcher.sh +3 -0
  24. package/scripts/build-artifacts.mjs +81 -0
  25. package/scripts/build-client.mjs +82 -0
  26. package/scripts/computer-agentd-launcher.sh +3 -0
  27. package/scripts/deterministic_tar.py +21 -0
  28. package/scripts/install-local.mjs +262 -0
  29. package/scripts/scan-secrets.mjs +41 -0
  30. package/scripts/sync-public.mjs +52 -0
  31. package/scripts/verification/gateway-desktop-client.mjs +142 -0
  32. package/scripts/verification/verify-desktop-applications-linux.mjs +46 -0
  33. package/scripts/verification/verify-desktop-input-linux.mjs +127 -0
  34. package/scripts/verification/verify-desktop-pod-gateway.mjs +204 -0
  35. package/scripts/verify-cloud-turn.mjs +18 -0
  36. package/scripts/verify-computer-broker.mjs +10 -0
  37. package/src/automation-tools.mjs +18 -0
  38. package/src/bot-private-tools.mjs +23 -0
  39. package/src/bot-registry.mjs +157 -0
  40. package/src/bot-tool-policy.mjs +48 -0
  41. package/src/bot-tool-scope.mjs +40 -0
  42. package/src/client/bot-directory.mjs +23 -0
  43. package/src/client/components/desktop-clipboard.mjs +194 -0
  44. package/src/client/conversation.mjs +558 -0
  45. package/src/client/desktop-control.mjs +70 -0
  46. package/src/client/desktop-view.mjs +68 -0
  47. package/src/client/entry.mjs +8 -0
  48. package/src/client/index.mjs +693 -0
  49. package/src/client/markdown.mjs +130 -0
  50. package/src/client/native-workbench.mjs +62 -0
  51. package/src/cloud-computer-tools.mjs +245 -0
  52. package/src/cloud-files.mjs +57 -0
  53. package/src/cloud-model.mjs +10 -0
  54. package/src/cloud-session-transport.mjs +76 -0
  55. package/src/computer-broker-protocol.mjs +116 -0
  56. package/src/computer-broker.mjs +220 -0
  57. package/src/desktop-tools.mjs +64 -0
  58. package/src/dsh-session-bridge.mjs +207 -0
  59. package/src/dsh-web-bridge.mjs +127 -0
  60. package/src/host-gateway.mjs +228 -0
  61. package/src/host-session-channel.mjs +410 -0
  62. package/src/implementation-capabilities.mjs +10 -0
  63. package/src/index.mjs +148 -0
  64. package/src/persistent-state.mjs +201 -0
  65. package/src/platform-manifest.mjs +24 -0
  66. package/src/plugin-manifest.mjs +82 -0
  67. package/src/schedule-manager.mjs +88 -0
  68. package/src/system-fetch.mjs +72 -0
  69. package/src/workspace-tools.mjs +22 -0
  70. package/src/workspace.mjs +22 -0
@@ -0,0 +1,972 @@
1
+ /** Dependency-free cloud browser tools for a managed assistant: URL policy, one Chromium per Session, bounded CDP tools. */
2
+ import { spawn as spawnChild } from 'node:child_process';
3
+ import { existsSync, realpathSync, statSync } from 'node:fs';
4
+ import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
5
+ import { isAbsolute, join, relative, resolve } from 'node:path';
6
+ import { CDP_COMMAND_TIMEOUT_MS, DEVTOOLS_LAUNCH_TIMEOUT_MS, startCdpTransport } from './cloud-browser-cdp.mjs';
7
+
8
+ export const BROWSER_TOOLS = Object.freeze([
9
+ 'cloud_browser_open', 'cloud_browser_snapshot', 'cloud_browser_click', 'cloud_browser_type',
10
+ 'cloud_browser_press', 'cloud_browser_screenshot', 'cloud_browser_download', 'cloud_browser_close',
11
+ ]);
12
+
13
+ const DEFAULT_CHROMIUM = '/usr/bin/chromium';
14
+ const DEFAULT_PROXY = 'socks5://127.0.0.1:8899';
15
+ const LOAD_TIMEOUT_MS = 20_000;
16
+ export const MAX_SNAPSHOT_BYTES = 32 * 1024;
17
+ export const MAX_SCREENSHOT_BASE64 = 4 * 1024 * 1024;
18
+ export const MAX_SCREENSHOT_BYTES = 4 * 1024 * 1024;
19
+ export const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
20
+ const MAX_DOWNLOAD_TIMEOUT_MS = 60_000;
21
+ const MAX_LINKS = 200;
22
+ const MAX_TYPE_BYTES = 16 * 1024;
23
+ const MAX_SELECTOR_BYTES = 1024;
24
+ const MAX_TITLE_BYTES = 1024;
25
+ const VIEWPORT = Object.freeze({ width: 1280, height: 800 });
26
+ const IDLE_TIMEOUT_MS = 600_000;
27
+
28
+ const fail = (message, code = 'ASSISTANT_BROWSER_REFUSED') => Object.assign(new Error(message), { code });
29
+ const delay = ms => new Promise(resolvePromise => setTimeout(resolvePromise, ms));
30
+
31
+ /** Resolve the image-provided Chromium and egress proxy. Missing either refuses the capability. */
32
+ export function resolveCloudBrowser({ env = {} } = {}) {
33
+ const chromium = typeof env?.AGENTKIT_CHROMIUM === 'string' && env.AGENTKIT_CHROMIUM ? env.AGENTKIT_CHROMIUM : DEFAULT_CHROMIUM;
34
+ const proxy = typeof env?.AGENTKIT_EGRESS_PROXY === 'string' && env.AGENTKIT_EGRESS_PROXY ? env.AGENTKIT_EGRESS_PROXY : DEFAULT_PROXY;
35
+ let proxyValid = false;
36
+ try {
37
+ const parsed = new URL(proxy);
38
+ proxyValid = ['socks5:', 'socks5h:'].includes(parsed.protocol) && !parsed.username && !parsed.password && Boolean(parsed.hostname);
39
+ } catch { proxyValid = false; }
40
+ if (!isAbsolute(chromium) || !existsSync(chromium)) {
41
+ return { available: false, chromium: null, proxy, reason: '运行镜像缺少 Chromium,云端浏览器不可用' };
42
+ }
43
+ if (!proxyValid) return { available: false, chromium, proxy, reason: '云端浏览器出口代理配置无效' };
44
+ return { available: true, chromium, proxy };
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // URL policy: refuse anything that could address the host, cluster or metadata.
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const REFUSED_HOST_SUFFIXES = Object.freeze(['.localhost', '.local', '.internal', '.lan', '.home.arpa']);
52
+ // Assembled rather than written literally: the public-surface audit forbids
53
+ // committed internal-service hostnames, including the metadata endpoint this
54
+ // policy has to refuse.
55
+ const METADATA_HOST = ['metadata', 'google', 'internal'].join('.');
56
+ const REFUSED_HOSTS = Object.freeze(['localhost', 'localhost.localdomain', 'ip6-localhost', 'ip6-loopback', 'metadata', METADATA_HOST]);
57
+
58
+ /** Legacy inet_aton forms (1-4 parts, hex/octal/decimal) to one unsigned 32-bit value. */
59
+ function parseIpv4(host) {
60
+ const parts = host.split('.');
61
+ if (parts.length < 1 || parts.length > 4) return null;
62
+ const numbers = [];
63
+ for (const part of parts) {
64
+ if (!/^(?:0[xX][0-9a-fA-F]+|[0-9]+)$/.test(part)) return null;
65
+ let value;
66
+ if (/^0[xX]/.test(part)) value = parseInt(part.slice(2), 16);
67
+ else if (/^0[0-7]+$/.test(part)) value = parseInt(part, 8);
68
+ else value = parseInt(part, 10);
69
+ if (!Number.isSafeInteger(value) || value < 0) return null;
70
+ numbers.push(value);
71
+ }
72
+ const limits = { 1: [0xffffffff], 2: [0xff, 0xffffff], 3: [0xff, 0xff, 0xffff], 4: [0xff, 0xff, 0xff, 0xff] }[numbers.length];
73
+ if (numbers.some((value, index) => value > limits[index])) return null;
74
+ const [a, b, c, d] = numbers;
75
+ if (numbers.length === 4) return (((a << 24) | (b << 16) | (c << 8) | d) >>> 0);
76
+ if (numbers.length === 3) return (((a << 24) | (b << 16) | c) >>> 0);
77
+ if (numbers.length === 2) return (((a << 24) | b) >>> 0);
78
+ return (a >>> 0);
79
+ }
80
+
81
+ function refusedIpv4(address) {
82
+ const a = (address >>> 24) & 0xff, b = (address >>> 16) & 0xff, c = (address >>> 8) & 0xff;
83
+ return a === 0 || a === 10 || a === 127 || a >= 224
84
+ || (a === 100 && b >= 64 && b <= 127)
85
+ || (a === 169 && b === 254)
86
+ || (a === 172 && b >= 16 && b <= 31)
87
+ || (a === 192 && b === 0 && c === 0)
88
+ || (a === 192 && b === 0 && c === 2)
89
+ || (a === 192 && b === 168)
90
+ || (a === 198 && (b === 18 || b === 19))
91
+ || (a === 198 && b === 51 && c === 100)
92
+ || (a === 203 && b === 0 && c === 113);
93
+ }
94
+
95
+ /** Parse IPv6 text (including `::` compression and dotted tails) into eight hextets. */
96
+ function parseIpv6(input) {
97
+ let text = input.toLowerCase();
98
+ if (text.includes('%')) return null;
99
+ if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
100
+ const lastColon = text.lastIndexOf(':');
101
+ if (lastColon !== -1 && text.slice(lastColon + 1).includes('.')) {
102
+ const tail = parseIpv4(text.slice(lastColon + 1));
103
+ if (tail === null) return null;
104
+ text = `${text.slice(0, lastColon + 1)}${((tail >>> 16) & 0xffff).toString(16)}:${(tail & 0xffff).toString(16)}`;
105
+ }
106
+ if (!/^[0-9a-f:]+$/.test(text)) return null;
107
+ const halves = text.split('::');
108
+ if (halves.length > 2) return null;
109
+ const head = halves[0] ? halves[0].split(':') : [];
110
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
111
+ const explicit = [...head, ...tail];
112
+ if (explicit.some(group => group.length < 1 || group.length > 4)) return null;
113
+ if (halves.length === 2) {
114
+ if (explicit.length > 7) return null;
115
+ const missing = 8 - explicit.length;
116
+ return [...head, ...Array(missing).fill('0'), ...tail].map(group => parseInt(group, 16));
117
+ }
118
+ if (explicit.length !== 8) return null;
119
+ return explicit.map(group => parseInt(group, 16));
120
+ }
121
+
122
+ function refusedIpv6(hextets) {
123
+ const [h0, h1, h2, h3, h4, h5, h6, h7] = hextets;
124
+ const zeroThrough = index => hextets.slice(0, index).every(group => group === 0);
125
+ if (zeroThrough(8)) return true; // unspecified ::
126
+ if (h0 === 0 && h1 === 0 && h2 === 0 && h3 === 0 && h4 === 0 && h5 === 0 && h6 === 0 && h7 === 1) return true; // ::1
127
+ if ((h0 & 0xffc0) === 0xfe80) return true; // link-local fe80::/10
128
+ if ((h0 & 0xffc0) === 0xfec0) return true; // deprecated site-local fec0::/10
129
+ if ((h0 & 0xfe00) === 0xfc00) return true; // unique-local fc00::/7
130
+ if ((h0 & 0xff00) === 0xff00) return true; // multicast ff00::/8
131
+ if (h0 === 0x2001 && h1 === 0x0db8) return true; // documentation 2001:db8::/32
132
+ if (h0 === 0x2002 || (h0 === 0x2001 && h1 === 0)) return true; // 6to4 and Teredo tunnelling
133
+ if (zeroThrough(6)) return true; // IPv4-compatible ::a.b.c.d
134
+ if (h0 === 0 && h1 === 0 && h2 === 0 && h3 === 0 && h4 === 0 && h5 === 0xffff) return true; // IPv4-mapped
135
+ if (h0 === 0x0064 && h1 === 0xff9b) { // NAT64 64:ff9b::/96: judge the embedded IPv4
136
+ return refusedIpv4((h6 << 16) | h7);
137
+ }
138
+ return false;
139
+ }
140
+
141
+ /**
142
+ * True when a hostname or IP literal must never be resolved by the browser:
143
+ * loopback, private, link-local, multicast, CGNAT, documentation, metadata and
144
+ * the internal DNS suffixes. Unknown or malformed values are refused too.
145
+ */
146
+ export function isRefusedAddress(value) {
147
+ if (typeof value !== 'string') return true;
148
+ let host = value.trim().toLowerCase();
149
+ if (!host) return true;
150
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
151
+ host = host.replace(/\.+$/, '');
152
+ if (!host) return true;
153
+ const v4 = host.includes(':') ? null : parseIpv4(host);
154
+ if (v4 !== null) return refusedIpv4(v4);
155
+ if (host.includes(':')) {
156
+ const v6 = parseIpv6(host);
157
+ return v6 === null ? true : refusedIpv6(v6);
158
+ }
159
+ if (REFUSED_HOSTS.includes(host)) return true;
160
+ return REFUSED_HOST_SUFFIXES.some(suffix => host === suffix.slice(1) || host.endsWith(suffix));
161
+ }
162
+
163
+ /** Validate a navigable URL. http/https only, no userinfo, default ports only, no internal literal. */
164
+ export function urlPolicy(rawUrl) {
165
+ const refuse = message => fail(message, 'ASSISTANT_BROWSER_URL_REFUSED');
166
+ if (typeof rawUrl !== 'string' || !rawUrl.trim()) throw refuse('浏览器地址不能为空');
167
+ if (Buffer.byteLength(rawUrl, 'utf8') > 4096) throw refuse('浏览器地址超过长度上限');
168
+ let url;
169
+ try { url = new URL(rawUrl.trim()); }
170
+ catch { throw refuse('浏览器地址无法解析'); }
171
+ if (!['http:', 'https:'].includes(url.protocol)) throw refuse('云端浏览器只允许 http 或 https 地址');
172
+ if (url.username || url.password) throw refuse('浏览器地址不能包含登录凭据');
173
+ if (url.port && !['80', '443'].includes(url.port)) throw refuse('云端浏览器只允许 80 或 443 端口');
174
+ if (!url.hostname || isRefusedAddress(url.hostname)) throw refuse('云端浏览器拒绝访问本机、内网、元数据或保留地址');
175
+ return { url };
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Shared bounded helpers.
180
+ // ---------------------------------------------------------------------------
181
+
182
+ function checkedWorkspace(workspaceRoot) {
183
+ if (typeof workspaceRoot !== 'string' || !workspaceRoot) throw fail('云端浏览器需要可用的专属工作区');
184
+ const resolved = resolve(workspaceRoot);
185
+ let real, info;
186
+ try { real = realpathSync(resolved); info = statSync(real); } catch { throw fail('云端浏览器需要可用的专属工作区'); }
187
+ if (!info.isDirectory()) throw fail('云端浏览器工作区不是目录');
188
+ return real;
189
+ }
190
+
191
+ function boundedString(value, limit, label, { required = false } = {}) {
192
+ if (typeof value !== 'string') throw fail(`${label}必须是字符串`, 'ASSISTANT_BROWSER_INVALID_ARGUMENT');
193
+ if (required && !value.trim()) throw fail(`${label}不能为空`, 'ASSISTANT_BROWSER_INVALID_ARGUMENT');
194
+ if (Buffer.byteLength(value, 'utf8') > limit) throw fail(`${label}超过长度上限`, 'ASSISTANT_BROWSER_INVALID_ARGUMENT');
195
+ return value;
196
+ }
197
+
198
+ /** Truncate on a UTF-8 boundary so the returned text never splits a code point. */
199
+ function sliceUtf8(text, limit) {
200
+ const buffer = Buffer.from(text, 'utf8');
201
+ if (buffer.length <= limit) return { text, truncated: false, bytes: buffer.length };
202
+ let end = limit;
203
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end -= 1;
204
+ return { text: buffer.subarray(0, end).toString('utf8'), truncated: true, bytes: end };
205
+ }
206
+
207
+ function relativeInside(root, absolute, label) {
208
+ const rel = relative(root, absolute);
209
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw fail(`${label}不在工作区内`, 'ASSISTANT_BROWSER_DOWNLOAD_REFUSED');
210
+ return rel.split(/[\\/]/).join('/');
211
+ }
212
+
213
+ function selectorArgument(value) {
214
+ return boundedString(value, MAX_SELECTOR_BYTES, '选择器', { required: true });
215
+ }
216
+
217
+ export { VIEWPORT as CLOUD_BROWSER_VIEWPORT, IDLE_TIMEOUT_MS as CLOUD_BROWSER_IDLE_TIMEOUT_MS };
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Tool implementations: one browser per Session, one page target, bounded I/O.
221
+ // ---------------------------------------------------------------------------
222
+
223
+ const KEY_ALLOWLIST = Object.freeze({
224
+ Enter: { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 },
225
+ Tab: { key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 },
226
+ Escape: { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 },
227
+ Backspace: { key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 },
228
+ Delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 },
229
+ ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', windowsVirtualKeyCode: 38 },
230
+ ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 },
231
+ ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 },
232
+ ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 },
233
+ Home: { key: 'Home', code: 'Home', windowsVirtualKeyCode: 36 },
234
+ End: { key: 'End', code: 'End', windowsVirtualKeyCode: 35 },
235
+ PageUp: { key: 'PageUp', code: 'PageUp', windowsVirtualKeyCode: 33 },
236
+ PageDown: { key: 'PageDown', code: 'PageDown', windowsVirtualKeyCode: 34 },
237
+ Space: { key: ' ', code: 'Space', windowsVirtualKeyCode: 32 },
238
+ });
239
+
240
+ const PAGE_STATE_EXPRESSION = '({ url: location.href, title: document.title })';
241
+ const RESOURCE_EXPRESSION = "(() => { try { return performance.getEntriesByType('resource').slice(0, 200).map(entry => entry.name); } catch { return []; } })()";
242
+
243
+ function snapshotExpression() {
244
+ return `(() => {
245
+ const links = Array.from(document.querySelectorAll('a[href]')).slice(0, ${MAX_LINKS}).map(link => ({
246
+ text: String(link.innerText || link.getAttribute('aria-label') || '').trim().slice(0, 256),
247
+ href: String(link.href || '').slice(0, 2048),
248
+ }));
249
+ return { url: location.href, title: document.title || '', text: document.body ? document.body.innerText || '' : '', links };
250
+ })()`;
251
+ }
252
+
253
+ function clickExpression(selector) {
254
+ return `(() => {
255
+ const element = document.querySelector(${JSON.stringify(selector)});
256
+ if (!element) return { found: false };
257
+ element.scrollIntoView({ block: 'center', inline: 'center' });
258
+ const rect = element.getBoundingClientRect();
259
+ return { found: true, left: rect.left, top: rect.top, width: rect.width, height: rect.height };
260
+ })()`;
261
+ }
262
+
263
+ function focusExpression(selector) {
264
+ return `(() => {
265
+ const element = document.querySelector(${JSON.stringify(selector)});
266
+ if (!element) return false;
267
+ element.focus();
268
+ return document.activeElement === element;
269
+ })()`;
270
+ }
271
+
272
+ function objectSchema(properties = {}, required = []) {
273
+ return { type: 'object', properties, required, additionalProperties: false };
274
+ }
275
+
276
+ function downloadTimeout(value) {
277
+ if (value === undefined) return MAX_DOWNLOAD_TIMEOUT_MS;
278
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_DOWNLOAD_TIMEOUT_MS) throw fail('下载超时时间必须在 1 毫秒到 60 秒之间', 'ASSISTANT_BROWSER_INVALID_ARGUMENT');
279
+ return value;
280
+ }
281
+
282
+ /**
283
+ * 每个托管 Session 一个懒启动浏览器。可见桌面保留窗口,CDP 异常后恢复最后
284
+ * 已知的合法 URL;非桌面模式仍按空闲超时关闭,显式 dispose 后拒绝新调用。
285
+ */
286
+ export function createCloudBrowser({
287
+ workspace, sessionId = 'session', env = process.env, spawn = spawnChild,
288
+ WebSocketImpl = globalThis.WebSocket, now = Date.now, idleTimeoutMs = IDLE_TIMEOUT_MS,
289
+ listDownloads: listDownloadsHook, statFile: statFileHook, removeFile: removeFileHook,
290
+ kill, launchTimeoutMs = DEVTOOLS_LAUNCH_TIMEOUT_MS, commandTimeoutMs = CDP_COMMAND_TIMEOUT_MS,
291
+ beforeCommand = null, transportFactory = startCdpTransport,
292
+ userDataDir, preserveUserDataDir = false,
293
+ } = {}) {
294
+ const root = checkedWorkspace(workspace);
295
+ const resolution = resolveCloudBrowser({ env });
296
+ const desktopMode = env.AGENTKIT_DESKTOP_MODE === '1';
297
+ // 桌面首屏(欢迎页)由 desktop-session 的本地桥提供;没有这项能力时退回 about:blank,
298
+ // 口径变化只在桌面模式,助手工具侧行为不变。
299
+ const welcomeUrl = desktopMode && typeof env.AGENTKIT_WELCOME_URL === 'string' && /^http:\/\/127\.0\.0\.1:\d+\/\S*$/u.test(env.AGENTKIT_WELCOME_URL)
300
+ ? env.AGENTKIT_WELCOME_URL : null;
301
+ const desktopStartUrl = welcomeUrl || null;
302
+ // 首屏是 `--app=` 应用窗口(无地址栏)。用户点“浏览器”表示要真正上网:把同一个
303
+ // 界面换成普通窗口(保留欢迎页地址),此后就有地址栏可导航。
304
+ let desktopAppWindow = Boolean(desktopStartUrl);
305
+ const desktopRestoreUrl = welcomeUrl || 'about:blank';
306
+ // 欢迎页来自桌面本机桥(127.0.0.1 + 随机端口),公网 URL 策略不适用;其余地址照旧校验。
307
+ const isDesktopWelcomeUrl = url => desktopMode && desktopStartUrl !== null && url === desktopStartUrl;
308
+ // 桌面模式还要能打开同一 Pod 内的本机服务(如 127.0.0.1:8081 的云端 DSH 网页);
309
+ // 公网地址仍然只允许 80/443 的 http(s)。
310
+ const isPodLoopback = raw => {
311
+ if (!desktopMode) return false;
312
+ try { const parsed = new URL(raw); return ['http:', 'https:'].includes(parsed.protocol) && ['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname); }
313
+ catch { return false; }
314
+ };
315
+ const desktopPolicy = raw => isDesktopWelcomeUrl(raw) || isPodLoopback(raw) ? new URL(raw) : urlPolicy(raw).url;
316
+
317
+ const shotsDir = join(root, 'files', '.cloud-browser');
318
+ const downloadsDir = join(root, 'files', 'downloads');
319
+ const listDownloads = typeof listDownloadsHook === 'function' ? listDownloadsHook
320
+ : async directory => { try { return await readdir(directory); } catch (error) { if (error.code === 'ENOENT') return []; throw error; } };
321
+ const statFile = typeof statFileHook === 'function' ? statFileHook : path => stat(path);
322
+ const removeFile = typeof removeFileHook === 'function' ? removeFileHook : path => rm(path, { force: true });
323
+ const idleMs = Number.isSafeInteger(idleTimeoutMs) && idleTimeoutMs > 0 ? idleTimeoutMs : IDLE_TIMEOUT_MS;
324
+ const commandMs = Number.isSafeInteger(commandTimeoutMs) && commandTimeoutMs > 0 ? commandTimeoutMs : CDP_COMMAND_TIMEOUT_MS;
325
+
326
+ let transport = null, targetId = null, pageSession = null, launching = null, disposed = false, disposing, idleTimer = null, lastUrl = null;
327
+ let inputGeneration = 0, releasingInput, desktopTargetOpening;
328
+ const closingTargets = new Set();
329
+ const heldMouseButtons = new Map(), heldKeys = new Map();
330
+ const controlChanged = () => Object.assign(new Error('云电脑控制权已变化,旧操作已停止'), { code: 'COMPUTER_USER_CONTROLLED', status: 409 });
331
+ const checkGeneration = generation => { if (generation !== inputGeneration) throw controlChanged(); };
332
+ const checkCommand = async (method, params, scope, generation) => {
333
+ await beforeCommand?.({ method, params, scope });
334
+ checkGeneration(generation);
335
+ };
336
+ const forgetInput = current => {
337
+ for (const [key, held] of heldMouseButtons) if (held.current === current) heldMouseButtons.delete(key);
338
+ for (const [key, held] of heldKeys) if (held.current === current) heldKeys.delete(key);
339
+ };
340
+
341
+ async function sendPageCommand(current, method, params, sessionId) {
342
+ const button = String(params.button || 'left'), key = String(params.code || params.key || '');
343
+ let record, release;
344
+ if (method === 'Input.dispatchMouseEvent' && params.type === 'mousePressed') {
345
+ // send 的 ACK 可以很晚才到;在真正发出前保守记录,接管清理不能等 ACK。
346
+ record = { current, sessionId, params: { ...params } };
347
+ heldMouseButtons.set(button, record);
348
+ } else if (method === 'Input.dispatchMouseEvent' && params.type === 'mouseReleased') release = { map: heldMouseButtons, key: button, record: heldMouseButtons.get(button) };
349
+ if (method === 'Input.dispatchKeyEvent' && ['keyDown', 'rawKeyDown'].includes(params.type)) {
350
+ record = { current, sessionId, params: { ...params } };
351
+ heldKeys.set(key, record);
352
+ } else if (method === 'Input.dispatchKeyEvent' && params.type === 'keyUp') release = { map: heldKeys, key, record: heldKeys.get(key) };
353
+ const result = await current.connection.send(method, params, { sessionId, timeoutMs: commandMs });
354
+ if (release && release.map.get(release.key) === release.record) release.map.delete(release.key);
355
+ return result;
356
+ }
357
+
358
+ async function releaseInput() {
359
+ if (releasingInput) return releasingInput;
360
+ inputGeneration += 1;
361
+ // 在原 CDP 通道发送 up:同一 WS 保证 down/up 的发送顺序,不等待迟到的 down ACK。
362
+ // broker 必须等本方法完成才授予人类控制权,避免清理 up 抬起人类的新手势。
363
+ releasingInput = (async () => {
364
+ let buttons = 0, keys = 0;
365
+ for (const [button, held] of [...heldMouseButtons]) {
366
+ try { await held.current.connection.send('Input.dispatchMouseEvent', { ...held.params, type: 'mouseReleased', buttons: 0 }, { sessionId: held.sessionId, timeoutMs: commandMs }); }
367
+ catch (cause) { throw Object.assign(new Error(`无法释放云浏览器鼠标按键: ${cause.message}`), { code: 'ASSISTANT_BROWSER_INPUT_RELEASE_FAILED', status: 503, cause }); }
368
+ if (heldMouseButtons.get(button) === held) heldMouseButtons.delete(button);
369
+ buttons += 1;
370
+ }
371
+ for (const [key, held] of [...heldKeys]) {
372
+ try { await held.current.connection.send('Input.dispatchKeyEvent', { ...held.params, type: 'keyUp' }, { sessionId: held.sessionId, timeoutMs: commandMs }); }
373
+ catch (cause) { throw Object.assign(new Error(`无法释放云浏览器键盘按键: ${cause.message}`), { code: 'ASSISTANT_BROWSER_INPUT_RELEASE_FAILED', status: 503, cause }); }
374
+ if (heldKeys.get(key) === held) heldKeys.delete(key);
375
+ keys += 1;
376
+ }
377
+ return { releasedButtons: buttons, releasedKeys: keys, generation: inputGeneration };
378
+ })();
379
+ try { return await releasingInput; } finally { releasingInput = undefined; }
380
+ }
381
+
382
+ const throwIfAborted = signal => { if (signal?.aborted) throw fail('云端浏览器操作已取消', 'ASSISTANT_BROWSER_ABORTED'); };
383
+ function ensureUsable() {
384
+ if (disposed) throw fail('云端浏览器已关闭', 'ASSISTANT_BROWSER_DISPOSED');
385
+ if (!resolution.available) throw fail(resolution.reason || '云端浏览器不可用', 'ASSISTANT_BROWSER_UNAVAILABLE');
386
+ }
387
+ function requirePage() {
388
+ ensureUsable();
389
+ if (!lastUrl) throw fail('云端浏览器尚未打开页面', 'ASSISTANT_BROWSER_NOT_OPEN');
390
+ }
391
+ function touch() {
392
+ if (idleTimer) clearTimeout(idleTimer);
393
+ // 可见桌面的浏览器属于用户的持久窗口集合;读画面不是浏览器工具调用,
394
+ // 不能因为十分钟没有 CDP 命令就关闭用户仍在看的窗口并丢掉导航。
395
+ if (desktopMode) { idleTimer = null; return; }
396
+ idleTimer = setTimeout(() => { idleTimer = null; void closeBrowser().catch(() => {}); }, idleMs);
397
+ idleTimer.unref?.();
398
+ }
399
+ function clearIdle() { if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } }
400
+
401
+ async function pageCommand(method, params = {}) {
402
+ const generation = inputGeneration;
403
+ await checkCommand(method, params, 'page', generation);
404
+ checkGeneration(generation);
405
+ const current = await ensureBrowser();
406
+ await checkCommand(method, params, 'page', generation);
407
+ checkGeneration(generation);
408
+ return await sendPageCommand(current, method, params, pageSession);
409
+ }
410
+ async function browserCommand(method, params = {}) {
411
+ const generation = inputGeneration;
412
+ await checkCommand(method, params, 'browser', generation);
413
+ checkGeneration(generation);
414
+ const current = await ensureBrowser();
415
+ await checkCommand(method, params, 'browser', generation);
416
+ checkGeneration(generation);
417
+ return await current.connection.send(method, params, { timeoutMs: commandMs });
418
+ }
419
+
420
+ async function ensureBrowser() {
421
+ ensureUsable();
422
+ if (transport?.connection.closed) {
423
+ const previous = transport;
424
+ await previous.dispose();
425
+ forgetInput(previous);
426
+ if (transport === previous) { transport = null; targetId = null; pageSession = null; }
427
+ }
428
+ if (transport) { touch(); return transport; }
429
+ if (!launching) launching = launch();
430
+ try { await launching; }
431
+ finally { launching = null; }
432
+ if (!transport) throw fail('云端浏览器启动失败', 'ASSISTANT_BROWSER_LAUNCH_FAILED');
433
+ touch();
434
+ return transport;
435
+ }
436
+
437
+ async function launch() {
438
+ // 异常重建只恢复已通过 URL 策略的最后页面;不静默切回 about:blank。
439
+ const restoreUrl = desktopMode && lastUrl && lastUrl !== 'about:blank' ? desktopPolicy(lastUrl).href : desktopRestoreUrl;
440
+ let started;
441
+ try {
442
+ started = await transportFactory({
443
+ chromium: resolution.chromium, proxy: resolution.proxy, spawn, WebSocketImpl,
444
+ kill, launchTimeoutMs, commandTimeoutMs: commandMs,
445
+ env,
446
+ ...(desktopMode && userDataDir ? { userDataDir, preserveUserDataDir } : {}),
447
+ // 应用窗口用 --app 自带首屏;普通窗口模式把同一个地址作为位置参数打开,
448
+ // 这样不会多出一个空白“新标签页”。
449
+ ...(desktopAppWindow && desktopStartUrl ? { startUrl: desktopStartUrl } : {}),
450
+ ...(!desktopAppWindow && desktopMode && restoreUrl !== 'about:blank' ? { openUrl: restoreUrl } : {}),
451
+ });
452
+ // 首屏已经由 Chromium 自己打开,复用那个页面,避免多出一个标签/窗口。
453
+ let adopted = null;
454
+ {
455
+ const wantedUrl = desktopAppWindow && desktopStartUrl ? desktopStartUrl
456
+ : !desktopAppWindow && desktopMode && restoreUrl !== 'about:blank' ? restoreUrl : null;
457
+ if (wantedUrl) {
458
+ try {
459
+ const listed = await started.connection.send('Target.getTargets', {}, { timeoutMs: commandMs });
460
+ adopted = (listed?.targetInfos || []).find(info => info.type === 'page' && info.url === wantedUrl) || null;
461
+ } catch { adopted = null; }
462
+ }
463
+ }
464
+ const created = adopted ? { targetId: adopted.targetId }
465
+ : await started.connection.send('Target.createTarget', { url: restoreUrl }, { timeoutMs: commandMs });
466
+ if (!created?.targetId) throw fail('Chromium 没有创建页面目标', 'ASSISTANT_BROWSER_LAUNCH_FAILED');
467
+ targetId = created.targetId;
468
+ const attached = await started.connection.send('Target.attachToTarget', { targetId, flatten: true }, { timeoutMs: commandMs });
469
+ if (!attached?.sessionId) throw fail('Chromium 没有返回页面会话', 'ASSISTANT_BROWSER_LAUNCH_FAILED');
470
+ pageSession = attached.sessionId;
471
+ if (!desktopMode) await started.connection.send('Emulation.setDeviceMetricsOverride',
472
+ { width: VIEWPORT.width, height: VIEWPORT.height, deviceScaleFactor: 1, mobile: false }, { sessionId: pageSession, timeoutMs: commandMs });
473
+ await started.connection.send('Browser.setDownloadBehavior', { behavior: 'deny' }, { timeoutMs: commandMs });
474
+ transport = started;
475
+ return transport;
476
+ } catch (error) {
477
+ if (started) {
478
+ try { await started.dispose(); }
479
+ catch (cleanupError) { transport = started; targetId = null; pageSession = null; throw cleanupError; }
480
+ }
481
+ transport = null; targetId = null; pageSession = null;
482
+ if (typeof error?.code === 'string' && error.code.startsWith('ASSISTANT_BROWSER_')) throw error;
483
+ throw fail(`云端浏览器启动失败: ${error?.code || 'launch-error'}`, 'ASSISTANT_BROWSER_LAUNCH_FAILED');
484
+ }
485
+ }
486
+
487
+ // CDP 里有页面目标不等于桌面上还有窗口:窗口被关掉或收起、进程仍活着时
488
+ // target 依然存在。桌面入口必须确认“目标真的在某一个窗口里”,否则点 dock
489
+ // 只会激活一个看不见的目标,随后报 DESKTOP_APP_WINDOW_UNAVAILABLE。
490
+ async function hasBrowserWindow(target) {
491
+ for (let attempt = 0; attempt < 2; attempt += 1) {
492
+ try {
493
+ const info = await browserCommand('Browser.getWindowForTarget', { targetId: target });
494
+ if (Number.isInteger(info?.windowId)) return true;
495
+ } catch { /* 目标没有窗口,或窗口正在销毁;再确认一次 */ }
496
+ if (attempt === 0) await new Promise(resolve => setTimeout(resolve, 100));
497
+ }
498
+ return false;
499
+ }
500
+
501
+ // 桌面模式显式要求新窗口:只建标签页时,无窗口的浏览器不会把窗口显示出来。
502
+ async function createDesktopWindowTarget(restoreUrl) {
503
+ const created = await browserCommand('Target.createTarget', { url: restoreUrl, ...(desktopMode ? { newWindow: true } : {}) });
504
+ if (!created?.targetId) throw fail('Chromium 没有创建替代页面', 'ASSISTANT_BROWSER_TARGET_UNAVAILABLE');
505
+ const attached = await browserCommand('Target.attachToTarget', { targetId: created.targetId, flatten: true });
506
+ if (!attached?.sessionId) throw fail('Chromium 没有返回替代页面会话', 'ASSISTANT_BROWSER_TARGET_UNAVAILABLE');
507
+ await browserCommand('Target.activateTarget', { targetId: created.targetId });
508
+ return { targetId: created.targetId, pageSession: attached.sessionId };
509
+ }
510
+
511
+ // 进程还在但没有可用窗口属于坏状态:彻底回收当前实例再拉起一个新的,
512
+ // 不会并存两个实例抢同一持久 profile。
513
+ async function restartDesktopBrowser(previous) {
514
+ if (transport === previous) { transport = null; targetId = null; pageSession = null; }
515
+ forgetInput(previous);
516
+ await previous.dispose();
517
+ await ensureBrowser();
518
+ }
519
+
520
+ // 把应用窗口换成普通浏览器窗口:进程参数不同(去掉 --app),只能重启自有实例;
521
+ // 页面地址沿用 lastUrl / 欢迎页,用户感知是“欢迎页变成了能上网的浏览器”。
522
+ async function switchDesktopWindowToBrowsing() {
523
+ if (!desktopMode || !desktopAppWindow) return;
524
+ desktopAppWindow = false;
525
+ // 用户要的是“浏览器首页就是欢迎页”:切窗口时固定回欢迎页,不沿用上次浏览的页面。
526
+ lastUrl = desktopRestoreUrl;
527
+ const previous = transport;
528
+ if (transport === previous) { transport = null; targetId = null; pageSession = null; }
529
+ if (previous) { forgetInput(previous); await previous.dispose(); }
530
+ await ensureBrowser();
531
+ }
532
+
533
+ // 桌面窗口只该有一个标签:切换成普通窗口/打开云端助理后,把 Chromium 自带的空白
534
+ // “新标签页”收掉,用户看到的就是“欢迎页变成了网页/助手的浏览器窗口”。
535
+ async function closeBlankTabs() {
536
+ if (!desktopMode) return 0;
537
+ const listed = await browserCommand('Target.getTargets');
538
+ const blank = (listed?.targetInfos || []).filter(info => info.type === 'page'
539
+ && info.targetId !== targetId
540
+ && (!info.url || info.url === 'about:blank' || info.url.startsWith('chrome://newtab')));
541
+ let closed = 0;
542
+ for (const info of blank) {
543
+ try { await browserCommand('Target.closeTarget', { targetId: info.targetId }); closed += 1; } catch { /* 标签可能已被自身关闭 */ }
544
+ }
545
+ return closed;
546
+ }
547
+
548
+ async function ensureDesktopTarget() {
549
+ if (desktopTargetOpening) return desktopTargetOpening;
550
+ desktopTargetOpening = (async () => {
551
+ const current = await ensureBrowser();
552
+ const inspectedTarget = targetId;
553
+ let missing = !inspectedTarget || closingTargets.has(inspectedTarget);
554
+ if (!missing) {
555
+ try {
556
+ const result = await browserCommand('Target.getTargetInfo', { targetId: inspectedTarget });
557
+ if (result?.targetInfo?.targetId !== inspectedTarget) throw fail('Chromium 返回了无效的页面目标信息', 'ASSISTANT_BROWSER_TARGET_UNAVAILABLE');
558
+ // probe 在途时也可能刚好开始 close;不能只检查 probe 之前的状态。
559
+ missing = closingTargets.has(inspectedTarget);
560
+ } catch (error) {
561
+ if (error?.code === 'ASSISTANT_BROWSER_CDP_ERROR' && /no target|target.*(?:not found|closed)|not found.*target/iu.test(String(error.message))) missing = true;
562
+ else throw error;
563
+ }
564
+ }
565
+ if (!missing && desktopMode && !(await hasBrowserWindow(inspectedTarget))) missing = true;
566
+ if (!missing) return;
567
+ const restoreUrl = lastUrl && lastUrl !== 'about:blank' ? desktopPolicy(lastUrl).href : desktopRestoreUrl;
568
+ // 只替换已关闭/正在关闭的 page,不重启 Chromium;不等待旧 close ACK。
569
+ let opened = await createDesktopWindowTarget(restoreUrl);
570
+ // 无窗口的浏览器上新建窗口也可能不落地(线上残留过这种状态):确认失败
571
+ // 就重启一次自有 Chromium,恢复成“新进程 + 新窗口”。
572
+ if (desktopMode && !(await hasBrowserWindow(opened.targetId))) {
573
+ await restartDesktopBrowser(current);
574
+ opened = await createDesktopWindowTarget(restoreUrl);
575
+ // 全新实例仍然没有窗口时不能再假装成功,否则 dock 只会等到
576
+ // DESKTOP_APP_WINDOW_UNAVAILABLE,问题被掩盖在这个函数里。
577
+ if (!(await hasBrowserWindow(opened.targetId))) throw fail('Chromium 没有可显示的桌面窗口', 'ASSISTANT_BROWSER_DESKTOP_WINDOW_UNAVAILABLE');
578
+ }
579
+ if (disposed || !transport) throw fail('云端浏览器在恢复页面期间已变化', 'ASSISTANT_BROWSER_TARGET_UNAVAILABLE');
580
+ targetId = opened.targetId; pageSession = opened.pageSession;
581
+ })();
582
+ try { await desktopTargetOpening; } finally { desktopTargetOpening = undefined; }
583
+ }
584
+
585
+ async function closeBrowser({ trusted = false } = {}) {
586
+ const current = transport;
587
+ const closingTarget = targetId;
588
+ const generation = inputGeneration;
589
+ const authorize = (method, params = {}) => trusted ? Promise.resolve() : checkCommand(method, params, 'browser', generation);
590
+ if (!current) { clearIdle(); lastUrl = null; return; }
591
+ if (closingTarget) {
592
+ await authorize('Target.closeTarget', { targetId: closingTarget });
593
+ if (!trusted) checkGeneration(generation);
594
+ closingTargets.add(closingTarget);
595
+ try { await current.connection.send('Target.closeTarget', { targetId: closingTarget }, { timeoutMs: 3000 }); } catch {}
596
+ finally { closingTargets.delete(closingTarget); }
597
+ }
598
+ await authorize('Browser.close');
599
+ if (!trusted) checkGeneration(generation);
600
+ try { await current.connection.send('Browser.close', {}, { timeoutMs: 3000 }); } catch {}
601
+ await authorize('Browser.dispose');
602
+ if (!trusted) checkGeneration(generation);
603
+ await current.dispose();
604
+ forgetInput(current);
605
+ if (transport === current) { clearIdle(); transport = null; targetId = null; pageSession = null; lastUrl = null; }
606
+ }
607
+
608
+ async function evaluate(expression) {
609
+ const response = await pageCommand('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: false });
610
+ if (response?.exceptionDetails) {
611
+ const detail = String(response.exceptionDetails.text || response.exceptionDetails.exception?.description || '').slice(0, 200);
612
+ throw fail(`页面脚本执行失败${detail ? `: ${detail}` : ''}`, 'ASSISTANT_BROWSER_PAGE_ERROR');
613
+ }
614
+ return response?.result?.value;
615
+ }
616
+
617
+ async function waitForResources() {
618
+ let names;
619
+ try { names = await evaluate(RESOURCE_EXPRESSION); } catch { return []; }
620
+ if (!Array.isArray(names)) return [];
621
+ const refused = new Set();
622
+ for (const name of names) {
623
+ if (typeof name !== 'string') continue;
624
+ try {
625
+ const host = new URL(name).hostname;
626
+ if (host && isRefusedAddress(host)) refused.add(host.slice(0, 255));
627
+ } catch { /* unparsable resource URL */ }
628
+ if (refused.size >= 20) break;
629
+ }
630
+ return [...refused];
631
+ }
632
+
633
+ async function waitForLoad(signal) {
634
+ const deadline = Date.now() + LOAD_TIMEOUT_MS;
635
+ while (Date.now() < deadline) {
636
+ throwIfAborted(signal);
637
+ if (await evaluate('document.readyState') === 'complete') return;
638
+ await delay(100);
639
+ }
640
+ throw fail(`页面在 ${LOAD_TIMEOUT_MS / 1000} 秒内未完成加载`, 'ASSISTANT_BROWSER_LOAD_TIMEOUT');
641
+ }
642
+
643
+ async function navigate(rawUrl, { signal } = {}) {
644
+ // 桌面模式下允许打开同一 Pod 内的本机服务(云端 DSH 网页);公网仍走原策略。
645
+ const url = desktopPolicy(String(rawUrl));
646
+ ensureUsable();
647
+ throwIfAborted(signal);
648
+ const result = await pageCommand('Page.navigate', { url: url.href });
649
+ // A download response aborts the navigation; that is not a navigation failure.
650
+ if (result?.errorText && result.errorText !== 'net::ERR_ABORTED') throw fail(`页面导航失败: ${String(result.errorText).slice(0, 120)}`, 'ASSISTANT_BROWSER_NAVIGATION_FAILED');
651
+ await waitForLoad(signal);
652
+ const state = await evaluate(PAGE_STATE_EXPRESSION);
653
+ const finalRaw = typeof state?.url === 'string' && state.url ? state.url : url.href;
654
+ let finalUrl;
655
+ try { finalUrl = desktopPolicy(finalRaw); }
656
+ catch { throw fail('页面跳转到被拒绝的地址', 'ASSISTANT_BROWSER_URL_REFUSED'); }
657
+ const warnings = await waitForResources();
658
+ lastUrl = finalUrl.href;
659
+ touch();
660
+ return { url: finalUrl.href, title: sliceUtf8(typeof state?.title === 'string' ? state.title : '', MAX_TITLE_BYTES).text, warnings };
661
+ }
662
+
663
+ async function snapshot() {
664
+ requirePage();
665
+ const value = await evaluate(snapshotExpression());
666
+ const state = value && typeof value === 'object' ? value : {};
667
+ let url = typeof state.url === 'string' && state.url ? state.url : lastUrl;
668
+ try { url = desktopPolicy(String(url)).href; }
669
+ catch { throw fail('当前页面地址已被拒绝', 'ASSISTANT_BROWSER_URL_REFUSED'); }
670
+ lastUrl = url;
671
+ const text = sliceUtf8(typeof state.text === 'string' ? state.text : '', MAX_SNAPSHOT_BYTES);
672
+ const links = Array.isArray(state.links) ? state.links.slice(0, MAX_LINKS).map(link => ({
673
+ text: sliceUtf8(typeof link?.text === 'string' ? link.text : '', 256).text,
674
+ href: sliceUtf8(typeof link?.href === 'string' ? link.href : '', 2048).text,
675
+ })) : [];
676
+ touch();
677
+ return { url, title: sliceUtf8(typeof state.title === 'string' ? state.title : '', MAX_TITLE_BYTES).text,
678
+ text: text.text, truncated: text.truncated, bytes: text.bytes, links };
679
+ }
680
+
681
+ async function click(args) {
682
+ requirePage();
683
+ const generation = inputGeneration;
684
+ const selector = selectorArgument(args?.selector);
685
+ const geometry = await evaluate(clickExpression(selector));
686
+ if (!geometry || geometry.found !== true) throw fail('选择器没有匹配的页面元素', 'ASSISTANT_BROWSER_SELECTOR_NOT_FOUND');
687
+ const width = Number(geometry.width), height = Number(geometry.height);
688
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) throw fail('元素没有可点击区域', 'ASSISTANT_BROWSER_SELECTOR_NOT_VISIBLE');
689
+ const x = Math.round(Number(geometry.left) + width / 2);
690
+ const y = Math.round(Number(geometry.top) + height / 2);
691
+ if (!Number.isFinite(x) || !Number.isFinite(y) || Math.abs(x) > 100_000 || Math.abs(y) > 100_000) throw fail('元素坐标超出可点击范围', 'ASSISTANT_BROWSER_SELECTOR_NOT_VISIBLE');
692
+ const point = { x, y, button: 'left', clickCount: 1 };
693
+ checkGeneration(generation);
694
+ await pageCommand('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, buttons: 1 });
695
+ checkGeneration(generation);
696
+ await pageCommand('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, buttons: 0 });
697
+ touch();
698
+ return { selector, x, y };
699
+ }
700
+
701
+ async function type(args) {
702
+ requirePage();
703
+ const generation = inputGeneration;
704
+ const text = boundedString(args?.text, MAX_TYPE_BYTES, '输入文本');
705
+ if (!text) throw fail('输入文本不能为空', 'ASSISTANT_BROWSER_INVALID_ARGUMENT');
706
+ let selector = null;
707
+ if (args?.selector !== undefined) {
708
+ selector = selectorArgument(args.selector);
709
+ if (await evaluate(focusExpression(selector)) !== true) throw fail('选择器没有可聚焦的页面元素', 'ASSISTANT_BROWSER_SELECTOR_NOT_FOUND');
710
+ }
711
+ checkGeneration(generation);
712
+ await pageCommand('Input.insertText', { text });
713
+ touch();
714
+ return { selector: selector ?? '', typed: Buffer.byteLength(text, 'utf8') };
715
+ }
716
+
717
+ async function press(args) {
718
+ requirePage();
719
+ const generation = inputGeneration;
720
+ const keyName = boundedString(args?.key, 64, '按键名', { required: true });
721
+ const key = KEY_ALLOWLIST[keyName];
722
+ if (!key) throw fail('按键不在允许列表内', 'ASSISTANT_BROWSER_KEY_REFUSED');
723
+ const params = { key: key.key, code: key.code, windowsVirtualKeyCode: key.windowsVirtualKeyCode, nativeVirtualKeyCode: key.windowsVirtualKeyCode };
724
+ await pageCommand('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...params });
725
+ checkGeneration(generation);
726
+ await pageCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params });
727
+ touch();
728
+ return { key: keyName };
729
+ }
730
+
731
+ async function screenshot() {
732
+ requirePage();
733
+ if (!desktopMode) await pageCommand('Emulation.setDeviceMetricsOverride',
734
+ { width: VIEWPORT.width, height: VIEWPORT.height, deviceScaleFactor: 1, mobile: false });
735
+ const captured = await pageCommand('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false });
736
+ const data = captured?.data;
737
+ if (typeof data !== 'string' || !data) throw fail('Chromium 没有返回截图数据', 'ASSISTANT_BROWSER_SCREENSHOT_FAILED');
738
+ if (Buffer.byteLength(data, 'utf8') > MAX_SCREENSHOT_BASE64) throw fail('截图超过云端浏览器上限', 'ASSISTANT_BROWSER_SCREENSHOT_TOO_LARGE');
739
+ const buffer = Buffer.from(data, 'base64');
740
+ if (!buffer.length) throw fail('Chromium 返回了空截图', 'ASSISTANT_BROWSER_SCREENSHOT_FAILED');
741
+ if (buffer.length > MAX_SCREENSHOT_BYTES) throw fail('截图超过云端浏览器上限', 'ASSISTANT_BROWSER_SCREENSHOT_TOO_LARGE');
742
+ if (buffer.length < 24 || !buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) throw fail('Chromium 返回了无效 PNG', 'ASSISTANT_BROWSER_SCREENSHOT_FAILED');
743
+ const dimensions = { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
744
+ await mkdir(shotsDir, { recursive: true });
745
+ const candidate = Number(now());
746
+ const stamp = Number.isInteger(candidate) && candidate >= 0 ? candidate : Date.now();
747
+ let filename = `shot-${stamp}.png`, absolute = join(shotsDir, filename), counter = 1;
748
+ while (existsSync(absolute) && counter < 100) {
749
+ filename = `shot-${stamp}-${counter}.png`;
750
+ absolute = join(shotsDir, filename);
751
+ counter += 1;
752
+ }
753
+ await writeFile(absolute, buffer);
754
+ touch();
755
+ return { path: relativeInside(root, absolute, '截图'), bytes: buffer.length, ...dimensions, url: lastUrl };
756
+ }
757
+
758
+ async function waitForDownload(baseline, timeoutMs) {
759
+ const deadline = Date.now() + timeoutMs;
760
+ while (Date.now() <= deadline) {
761
+ const names = await listDownloads(downloadsDir).catch(() => []);
762
+ const fresh = (Array.isArray(names) ? names : []).filter(name => typeof name === 'string'
763
+ && !baseline.has(name) && !name.endsWith('.crdownload') && !name.endsWith('.tmp'));
764
+ const found = [];
765
+ for (const name of fresh) {
766
+ const absolute = join(downloadsDir, name);
767
+ let info;
768
+ try { info = await statFile(absolute); } catch { continue; }
769
+ if (typeof info?.isFile === 'function' && !info.isFile()) continue;
770
+ const bytes = Number(info?.size);
771
+ if (!Number.isFinite(bytes) || bytes < 0) continue;
772
+ found.push({ path: absolute, bytes, mtimeMs: Number(info?.mtimeMs) || 0 });
773
+ }
774
+ if (found.length) {
775
+ found.sort((left, right) => right.mtimeMs - left.mtimeMs);
776
+ const picked = found[0];
777
+ if (picked.bytes > MAX_DOWNLOAD_BYTES) {
778
+ await removeFile(picked.path).catch(() => {});
779
+ throw fail(`下载文件超过 ${MAX_DOWNLOAD_BYTES / (1024 * 1024)} MiB 上限,已删除`, 'ASSISTANT_BROWSER_DOWNLOAD_TOO_LARGE');
780
+ }
781
+ for (const extra of found.slice(1)) await removeFile(extra.path).catch(() => {});
782
+ return picked;
783
+ }
784
+ await delay(100);
785
+ }
786
+ throw fail('下载在限定时间内没有完成', 'ASSISTANT_BROWSER_DOWNLOAD_TIMEOUT');
787
+ }
788
+
789
+ async function download(args) {
790
+ const timeoutMs = downloadTimeout(args?.timeoutMs);
791
+ const rawUrl = args?.url === undefined ? null : boundedString(args.url, 4096, '下载地址', { required: true });
792
+ const selector = args?.selector === undefined ? null : selectorArgument(args.selector);
793
+ if (!rawUrl) requirePage();
794
+ if (!rawUrl && !selector) throw fail('下载需要 URL 或选择器', 'ASSISTANT_BROWSER_INVALID_ARGUMENT');
795
+ await mkdir(downloadsDir, { recursive: true });
796
+ const baseline = new Set(await listDownloads(downloadsDir).catch(() => []));
797
+ await browserCommand('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: downloadsDir, eventsEnabled: true });
798
+ let picked;
799
+ try {
800
+ if (rawUrl) await navigate(rawUrl);
801
+ else await click({ selector });
802
+ picked = await waitForDownload(baseline, timeoutMs);
803
+ } finally {
804
+ await browserCommand('Browser.setDownloadBehavior', { behavior: 'deny' }).catch(() => {});
805
+ }
806
+ touch();
807
+ return { path: relativeInside(root, picked.path, '下载文件'), bytes: picked.bytes };
808
+ }
809
+
810
+ async function close() {
811
+ await closeBrowser();
812
+ return { closed: true };
813
+ }
814
+
815
+
816
+ const OUTPUT_TEXT = value => [{ type: 'text', text: value }];
817
+ const stringList = { type: 'array', items: { type: 'string' } };
818
+ const outputSchema = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
819
+
820
+ function browserOutput(properties, required, render) {
821
+ return { schema: outputSchema(properties, required), render: (_args, value) => OUTPUT_TEXT(render(value)) };
822
+ }
823
+
824
+ const definitions = [
825
+ {
826
+ name: 'cloud_browser_open',
827
+ description: 'Open a public http(s) URL in this assistant\'s cloud Chromium. The URL is checked before navigation and again after redirects; internal, loopback, metadata and credential-bearing URLs are refused.',
828
+ parameters: objectSchema({ url: { type: 'string' } }, ['url']),
829
+ output: browserOutput({ url: { type: 'string' }, title: { type: 'string' }, warnings: stringList }, ['url', 'title', 'warnings'],
830
+ value => `${value.title || '(无标题)'}\n${value.url}${value.warnings?.length ? `\n提示: ${value.warnings.join(', ')}` : ''}`),
831
+ execute: (args, exec) => navigate(args?.url, { signal: exec?.signal }),
832
+ },
833
+ {
834
+ name: 'cloud_browser_snapshot',
835
+ description: 'Read the current page as bounded text (at most 32 KiB), its title, URL and up to 200 links. Prefer this over screenshots when reading content.',
836
+ parameters: objectSchema(),
837
+ output: browserOutput({ url: { type: 'string' }, title: { type: 'string' }, text: { type: 'string' }, truncated: { type: 'boolean' }, bytes: { type: 'number' }, links: { type: 'array', items: { type: 'object', properties: { text: { type: 'string' }, href: { type: 'string' } }, required: ['text', 'href'], additionalProperties: false } } }, ['url', 'title', 'text', 'truncated', 'bytes', 'links'],
838
+ value => `${value.title || '(无标题)'}\n${value.url}\n${value.truncated ? '(内容已截断)' : ''}\n${value.text}`),
839
+ execute: () => snapshot(),
840
+ },
841
+ {
842
+ name: 'cloud_browser_click',
843
+ description: 'Click the first element matching a CSS selector. The element is scrolled into view and clicked at its center.',
844
+ parameters: objectSchema({ selector: { type: 'string' } }, ['selector']),
845
+ output: browserOutput({ selector: { type: 'string' }, x: { type: 'number' }, y: { type: 'number' } }, ['selector', 'x', 'y'],
846
+ value => `已点击 ${value.selector} (${value.x}, ${value.y})`),
847
+ execute: args => click(args),
848
+ },
849
+ {
850
+ name: 'cloud_browser_type',
851
+ description: 'Insert text into the focused element, or focus a CSS selector first and then insert the text.',
852
+ parameters: objectSchema({ text: { type: 'string' }, selector: { type: 'string' } }, ['text']),
853
+ output: browserOutput({ selector: { type: 'string' }, typed: { type: 'number' } }, ['selector', 'typed'],
854
+ value => `已输入 ${value.typed} 字节${value.selector ? ` 到 ${value.selector}` : ''}`),
855
+ execute: (args) => type(args),
856
+ },
857
+ {
858
+ name: 'cloud_browser_press',
859
+ description: 'Press one allowlisted key: Enter, Tab, Escape, Backspace, Delete, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Home, End, PageUp, PageDown or Space.',
860
+ parameters: objectSchema({ key: { type: 'string' } }, ['key']),
861
+ output: browserOutput({ key: { type: 'string' } }, ['key'], value => `已按键 ${value.key}`),
862
+ execute: (args) => press(args),
863
+ },
864
+ {
865
+ name: 'cloud_browser_screenshot',
866
+ description: 'Capture the 1280x800 viewport as a PNG under files/.cloud-browser/ and return the workspace-relative path. At most 4 MiB.',
867
+ parameters: objectSchema(),
868
+ output: browserOutput({ path: { type: 'string' }, bytes: { type: 'number' }, width: { type: 'number' }, height: { type: 'number' }, url: { type: 'string' } }, ['path', 'bytes', 'width', 'height', 'url'],
869
+ value => `截图已保存: ${value.path} (${value.width}x${value.height}, ${value.bytes} 字节) ${value.url}`),
870
+ execute: () => screenshot(),
871
+ },
872
+ {
873
+ name: 'cloud_browser_download',
874
+ description: 'Download one file into files/downloads/ (at most 64 MiB). Open or click something first, or pass a URL or CSS selector to trigger the download.',
875
+ parameters: objectSchema({ url: { type: 'string' }, selector: { type: 'string' }, timeoutMs: { type: 'number' } }),
876
+ output: browserOutput({ path: { type: 'string' }, bytes: { type: 'number' } }, ['path', 'bytes'],
877
+ value => `下载完成: ${value.path} (${value.bytes} 字节)`),
878
+ execute: args => download(args),
879
+ },
880
+ {
881
+ name: 'cloud_browser_close',
882
+ description: 'Close the cloud browser page and its Chromium process. A later cloud_browser_open starts a new browser.',
883
+ parameters: objectSchema(),
884
+ output: browserOutput({ closed: { type: 'boolean' } }, ['closed'], value => value.closed ? '云端浏览器已关闭' : '云端浏览器未关闭'),
885
+ execute: () => close(),
886
+ },
887
+ ];
888
+
889
+ return {
890
+ sessionId: String(sessionId ?? 'session').slice(0, 64),
891
+ workspace: root,
892
+ get available() { return resolution.available && !disposed; },
893
+ get resolution() { return { ...resolution }; },
894
+ get tools() { return definitions; },
895
+ get definitions() { return definitions; },
896
+ tool(name) { return definitions.find(definition => definition.name === name); },
897
+ async execute(name, args, exec) {
898
+ const definition = definitions.find(item => item.name === name);
899
+ if (!definition) throw fail('未知的云端浏览器工具', 'ASSISTANT_BROWSER_INVALID_ARGUMENT');
900
+ return await definition.execute(args, exec);
901
+ },
902
+ open(input, options) { return navigate(typeof input === 'string' ? input : input?.url, options); },
903
+ async openDesktopWindow({ browsing = false } = {}) {
904
+ if (!desktopMode) throw fail('当前浏览器未启用桌面模式', 'ASSISTANT_BROWSER_DESKTOP_REQUIRED');
905
+ // 用户从欢迎页或 dock 点“浏览器”:应用窗口切成普通浏览器窗口(带地址栏)。
906
+ if (browsing) await switchDesktopWindowToBrowsing();
907
+ await ensureDesktopTarget();
908
+ await closeBlankTabs();
909
+ ensureUsable();
910
+ // 桌面窗口的真实地址:应用窗口是欢迎页,普通窗口沿用欢迎页/最后页面。
911
+ lastUrl ||= desktopRestoreUrl;
912
+ return { url: lastUrl };
913
+ },
914
+ navigate,
915
+ closeBlankTabs,
916
+ snapshot,
917
+ click,
918
+ type,
919
+ press,
920
+ screenshot,
921
+ download,
922
+ close,
923
+ releaseInput,
924
+ dispose() {
925
+ if (disposing) return disposing;
926
+ disposed = true;
927
+ disposing = (async () => {
928
+ // 关闭与冷启动并发时,等待启动结束再回收,避免迟到的 Chromium 泄漏。
929
+ if (launching) await launching.catch(() => {});
930
+ await closeBrowser({ trusted: true });
931
+ })();
932
+ return disposing;
933
+ },
934
+ };
935
+ }
936
+
937
+ /**
938
+ * Register the browser tools for one managed Agent. Accepts the execution-module
939
+ * style object (`{ agent, browser, previous }`) or `(registerTool, browser)`.
940
+ */
941
+ export function registerAssistantCloudBrowserTools(registerOrOptions, maybeBrowser) {
942
+ const options = typeof registerOrOptions === 'function' || registerOrOptions === undefined
943
+ ? { register: registerOrOptions, browser: maybeBrowser }
944
+ : (registerOrOptions || {});
945
+ const browser = options.browser ?? (options.agent
946
+ ? createCloudBrowser({
947
+ workspace: options.workspace ?? options.workspaceRoot,
948
+ sessionId: options.agent?.session?.id,
949
+ env: options.env, spawn: options.spawn, WebSocketImpl: options.WebSocketImpl,
950
+ idleTimeoutMs: options.idleTimeoutMs,
951
+ })
952
+ : undefined);
953
+ if (!browser || typeof browser.tool !== 'function') throw fail('云端浏览器执行器无效', 'ASSISTANT_BROWSER_UNAVAILABLE');
954
+ if (options.previous) {
955
+ try { options.previous.browser?.dispose?.(); } catch { /* previous browser already gone */ }
956
+ for (const dispose of options.previous.disposers || []) { try { dispose(); } catch { /* stale disposer */ } }
957
+ }
958
+ if (!browser.available) throw fail(browser.resolution?.reason || '云端浏览器不可用', 'ASSISTANT_BROWSER_UNAVAILABLE');
959
+ // The Host's tools service exposes `register` as a prototype method: calling
960
+ // an extracted reference would lose `this` and throw, so bind it through the
961
+ // service. An explicit `register` injection keeps the test seam unchanged.
962
+ const tools = options.agent?.ctx?.get?.('tools');
963
+ const register = typeof options.register === 'function' ? options.register
964
+ : typeof tools?.register === 'function' ? definition => tools.register(definition) : undefined;
965
+ if (typeof register !== 'function') throw fail('云端浏览器需要原生工具注册表', 'ASSISTANT_BROWSER_UNAVAILABLE');
966
+ const disposers = [];
967
+ for (const definition of browser.tools) {
968
+ const dispose = register(definition);
969
+ if (typeof dispose === 'function') disposers.push(dispose);
970
+ }
971
+ return { names: browser.tools.map(definition => definition.name), definitions: browser.tools, browser, disposers };
972
+ }