@kingsoftcloud/agentkit-personal-assistant 0.1.9 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ const require = createRequire(import.meta.url);
4
+
5
+ function manifestValue(path, field) {
6
+ try {
7
+ const value = require(path)?.[field];
8
+ return typeof value === 'string' && value.trim() ? value.trim() : 'unknown';
9
+ } catch {
10
+ return 'unknown';
11
+ }
12
+ }
13
+
14
+ function clean(value) {
15
+ const text = String(value ?? '').trim();
16
+ return text && !/[\u0000\r\n]/u.test(text) ? text.slice(0, 160) : 'unknown';
17
+ }
18
+
19
+ /**
20
+ * Build identity is deliberately read from the image environment first. The
21
+ * package/manifest fallbacks keep local and packaged computer artifacts
22
+ * diagnosable even when the deployment did not inject release metadata.
23
+ */
24
+ export function createBuildInfo(env = process.env) {
25
+ return Object.freeze({
26
+ packageVersion: clean(env.AGENTKIT_PERSONAL_ASSISTANT_VERSION || manifestValue('../package.json', 'version')),
27
+ manifestVersion: clean(env.AGENTKIT_PERSONAL_ASSISTANT_MANIFEST_VERSION || manifestValue('../personal-assistant.plugin.json', 'version')),
28
+ runtimeBuild: clean(env.AGENTKIT_RUNTIME_BUILD || env.AGENTKIT_BUILD_ID),
29
+ computerImage: clean(env.AGENTKIT_COMPUTER_IMAGE || env.AGENTKIT_IMAGE || env.AGENTKIT_IMAGE_DIGEST),
30
+ buildTimestamp: clean(env.AGENTKIT_BUILD_TIMESTAMP),
31
+ });
32
+ }
33
+
34
+ export const DESKTOP_DOCK_LAYOUT = Object.freeze({
35
+ position: 'bottom-center',
36
+ panelPosition: 'bottom center horizontal',
37
+ shrink: true,
38
+ iconCount: 3,
39
+ height: 56,
40
+ });
@@ -15,6 +15,47 @@ const persistentProfileOwners = new Map();
15
15
  const fail = (message, code = 'ASSISTANT_BROWSER_CDP_UNAVAILABLE') => Object.assign(new Error(message), { code });
16
16
  const delay = ms => new Promise(resolvePromise => setTimeout(resolvePromise, ms));
17
17
 
18
+ /**
19
+ * 桌面窗口几何预算(均为实测值,改动前先看 test/cloud-browser-lifecycle.test.mjs 的注释):
20
+ * - 屏幕 1280x800(runtime/desktop-session.mjs 的 DESKTOP_WIDTH/HEIGHT,Xvfb 分辨率)
21
+ * - 底部 tint2 dock 占 68px(panel_size 56 + panel_margin 12)且带 strut,因此
22
+ * `xprop -root _NET_WORKAREA` = 0,0,1280,732:窗口真正可见的高度只有 732
23
+ * - Chromium 窗口不带 WM 装饰(实测 `_NET_FRAME_EXTENTS` = 0,0,0,0,标签栏和 —□✕
24
+ * 都是 Chromium 自绘);fluxbox 只给普通 X11 客户端加装饰(xterm 实测 1,1,30,5)。
25
+ * 这里预留的 33px 是装饰保守余量,不是 Chromium 当前的实际开销。
26
+ *
27
+ * Chromium 的 `--window-size` 是客户区(无装饰时即整个窗口)尺寸:客户区 800 比工作区
28
+ * 732 高 68px,fluxbox 会把窗口底边贴到工作区底边、把顶部推出屏幕。0.1.23 镜像里不带
29
+ * --start-maximized 的对照实测:
30
+ * --window-size=1280,800 → xwininfo 0,-67 1279x799(底边正好 732,顶部 67px 在屏外)
31
+ * --window-size=1278,699 → xwininfo 0,0 1278x699(完整可见)
32
+ * CDP 侧同一个现象:旧参数下 Browser.getWindowBounds = {left:0, top:-67, width:1279,
33
+ * height:799, windowState:'normal'},setWindowBounds({windowState:'maximized'}) 后立刻
34
+ * 变成 {0,0,1280x732, maximized}。用户看到的就是“标题栏(自绘标签栏)在屏幕外 +
35
+ * 页面底部被 dock 压住”。
36
+ */
37
+ export const DESKTOP_SCREEN_WIDTH = 1280;
38
+ export const DESKTOP_SCREEN_HEIGHT = 800;
39
+ /** tint2 dock 占用的底部高度(带 strut,扣掉后才是可用工作区高度 732)。 */
40
+ export const DESKTOP_DOCK_HEIGHT = 68;
41
+ /**
42
+ * 装饰保守余量:fluxbox 对普通 X11 客户端实测 `_NET_FRAME_EXTENTS` = 1,1,30,5
43
+ * (左右 1、上 30、下 5,即竖向 35 / 横向 2);Chromium 自绘窗口实测 0,0,0,0。
44
+ * 这里按竖向 33 / 横向 2 预留,属于"未来换成带 WM 装饰的 Chromium 也不越界"的
45
+ * 保守预算——窗口实际会比预算更矮,不会超出可见区。
46
+ */
47
+ export const DESKTOP_DECORATION_HORIZONTAL = 2;
48
+ export const DESKTOP_DECORATION_VERTICAL = 33;
49
+
50
+ /**
51
+ * 让窗口完整落在「屏幕 - 底部 dock」可见区内的客户区尺寸:1278x699。
52
+ * 不传参时按整屏扣掉 dock 高度计算;调用方也可以显式给可用高度。
53
+ * 其中 33px 是装饰保守余量(见上面的几何预算说明)。
54
+ */
55
+ export function desktopWindowClientSize({ width = DESKTOP_SCREEN_WIDTH, height = DESKTOP_SCREEN_HEIGHT - DESKTOP_DOCK_HEIGHT } = {}) {
56
+ return { width: width - DESKTOP_DECORATION_HORIZONTAL, height: height - DESKTOP_DECORATION_VERTICAL };
57
+ }
58
+
18
59
  /**
19
60
  * Chromium flags for the managed image. Chromium's own sandbox is unavailable in
20
61
  * most managed containers, so the egress proxy stays authoritative for network
@@ -28,17 +69,21 @@ const delay = ms => new Promise(resolvePromise => setTimeout(resolvePromise, ms)
28
69
  * 不改变 CDP/自动化语义,也不影响页面能力。
29
70
  */
30
71
  export function chromiumArgs({ userDataDir, proxy, windowSize = '1280,800', desktop = false, startUrl, openUrl }) {
72
+ const desktopClient = desktop ? desktopWindowClientSize() : null;
31
73
  return [
32
- ...(desktop ? ['--ozone-platform=x11', '--window-position=520,90', '--test-type', '--lang=zh-CN', '--accept-lang=zh-CN,zh', '--class=Chromium', '--hide-crash-restore-bubble'] : ['--headless=new']),
33
- // 应用窗口承载首屏:没有地址栏、没有多余标签条。
34
- ...(desktop && startUrl ? [`--app=${startUrl}`] : []),
35
- // 普通窗口模式:把首页作为位置参数交给 Chromium,避免先出一个空白标签再补一个页面。
36
- ...(desktop && !startUrl && openUrl ? [openUrl] : []),
74
+ ...(desktop ? ['--ozone-platform=x11', '--test-type', '--lang=zh-CN', '--accept-lang=zh-CN,zh', '--class=Chromium', '--hide-crash-restore-bubble'] : ['--headless=new']),
75
+ // 欢迎页是云电脑的门面,必须铺满整块 1280x800 屏幕:`--app` 无地址栏 +
76
+ // `--kiosk` 全屏(旧实现是 740x620 固定窗口偏在右下角,四圈都是灰底)。
77
+ ...(desktop && startUrl ? [`--app=${startUrl}`, '--kiosk', '--window-position=0,0'] : []),
78
+ // 普通窗口模式:把首页作为位置参数交给 Chromium,避免先出一个空白标签再补一个页面;同时最大化。
79
+ ...(desktop && !startUrl && openUrl ? ['--window-position=0,0', '--start-maximized', openUrl] : []),
80
+ ...(desktop && !startUrl && !openUrl ? ['--window-position=0,0', '--start-maximized'] : []),
37
81
  '--disable-gpu', '--disable-dev-shm-usage', '--no-sandbox',
38
82
  '--no-first-run', '--no-default-browser-check', '--disable-extensions', '--disable-sync',
39
83
  '--disable-background-networking', '--disable-breakpad', '--disable-crash-reporter',
40
84
  '--disable-translate', '--mute-audio', '--hide-scrollbars',
41
- `--window-size=${desktop ? '740,620' : windowSize}`,
85
+ // 客户区尺寸必须扣掉可用的装饰余量(见上面的几何预算注释),否则窗口会高于工作区。
86
+ `--window-size=${desktopClient ? `${desktopClient.width},${desktopClient.height}` : windowSize}`,
42
87
  '--remote-debugging-port=0', '--remote-debugging-address=127.0.0.1',
43
88
  `--user-data-dir=${userDataDir}`,
44
89
  `--proxy-server=${proxy}`,
@@ -141,7 +186,7 @@ export async function launchChromium({
141
186
  // 桌面窗口给用户看:界面与页面语言都用简体中文;LC_ALL 会盖过 LANG,桌面模式不能设。
142
187
  // 无头自动化不改变既有口径(历史行为是 C.UTF-8 + 默认语言)。
143
188
  ...(desktop
144
- ? { LANG: 'zh_CN.UTF-8', LANGUAGE: 'zh_CN:zh' }
189
+ ? { LANG: 'zh_CN.UTF-8', LANGUAGE: 'zh_CN:zh', LC_CTYPE: 'C.UTF-8' }
145
190
  : { LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8' }),
146
191
  ...(timeZone ? { TZ: timeZone } : {}),
147
192
  ...(desktop ? { DISPLAY: env.DISPLAY || ':99' } : {}),
@@ -3,7 +3,10 @@ import { spawn as spawnChild } from 'node:child_process';
3
3
  import { existsSync, realpathSync, statSync } from 'node:fs';
4
4
  import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
5
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';
6
+ import {
7
+ CDP_COMMAND_TIMEOUT_MS, DESKTOP_SCREEN_HEIGHT, DESKTOP_SCREEN_WIDTH, DEVTOOLS_LAUNCH_TIMEOUT_MS,
8
+ desktopWindowClientSize, startCdpTransport,
9
+ } from './cloud-browser-cdp.mjs';
7
10
 
8
11
  export const BROWSER_TOOLS = Object.freeze([
9
12
  'cloud_browser_open', 'cloud_browser_snapshot', 'cloud_browser_click', 'cloud_browser_type',
@@ -13,6 +16,8 @@ export const BROWSER_TOOLS = Object.freeze([
13
16
  const DEFAULT_CHROMIUM = '/usr/bin/chromium';
14
17
  const DEFAULT_PROXY = 'socks5://127.0.0.1:8899';
15
18
  const LOAD_TIMEOUT_MS = 20_000;
19
+ const DEFAULT_NAVIGATION_MIN_INTERVAL_MS = 800;
20
+ const DEFAULT_CHALLENGE_COOLDOWN_MS = 30_000;
16
21
  export const MAX_SNAPSHOT_BYTES = 32 * 1024;
17
22
  export const MAX_SCREENSHOT_BASE64 = 4 * 1024 * 1024;
18
23
  export const MAX_SCREENSHOT_BYTES = 4 * 1024 * 1024;
@@ -24,10 +29,26 @@ const MAX_SELECTOR_BYTES = 1024;
24
29
  const MAX_TITLE_BYTES = 1024;
25
30
  const VIEWPORT = Object.freeze({ width: 1280, height: 800 });
26
31
  const IDLE_TIMEOUT_MS = 600_000;
32
+ const CHALLENGE_WARNING = '检测到验证码或站点风控页,已暂停自动点击/输入;请在可见云浏览器中手动完成验证后再继续';
27
33
 
28
34
  const fail = (message, code = 'ASSISTANT_BROWSER_REFUSED') => Object.assign(new Error(message), { code });
29
35
  const delay = ms => new Promise(resolvePromise => setTimeout(resolvePromise, ms));
30
36
 
37
+ /**
38
+ * 识别常见验证码/反自动化页面。这里只做合规的风险提示与停手,
39
+ * 不尝试绕过验证码、伪造浏览器指纹或自动提交验证。
40
+ */
41
+ export function detectBrowserChallenge({ url = '', title = '', text = '' } = {}) {
42
+ const haystack = [url, title, text].map(value => String(value ?? '')).join('\n').slice(0, 16_384);
43
+ if (!haystack) return null;
44
+ const patterns = [
45
+ /captcha|re-?captcha|hcaptcha|verify\s+(?:you\s+are\s+)?human|security\s+check|unusual\s+traffic|access\s+denied|too\s+many\s+requests|rate\s*limit|challenge/iu,
46
+ /验证码|人机验证|滑块验证|安全验证|访问频繁|异常流量|请求过于频繁|(?:请|需要)(?:完成|通过)(?:验证码|人机|安全|滑块)验证|风控/iu,
47
+ ];
48
+ const matched = patterns.some(pattern => pattern.test(haystack));
49
+ return matched ? { warning: CHALLENGE_WARNING, code: 'ASSISTANT_BROWSER_CHALLENGE_REQUIRED' } : null;
50
+ }
51
+
31
52
  /** Resolve the image-provided Chromium and egress proxy. Missing either refuses the capability. */
32
53
  export function resolveCloudBrowser({ env = {} } = {}) {
33
54
  const chromium = typeof env?.AGENTKIT_CHROMIUM === 'string' && env.AGENTKIT_CHROMIUM ? env.AGENTKIT_CHROMIUM : DEFAULT_CHROMIUM;
@@ -237,7 +258,7 @@ const KEY_ALLOWLIST = Object.freeze({
237
258
  Space: { key: ' ', code: 'Space', windowsVirtualKeyCode: 32 },
238
259
  });
239
260
 
240
- const PAGE_STATE_EXPRESSION = '({ url: location.href, title: document.title })';
261
+ const PAGE_STATE_EXPRESSION = '(() => ({ url: location.href, title: document.title, text: document.body ? String(document.body.innerText || document.body.textContent || "").slice(0, 8192) : "" }))()';
241
262
  const RESOURCE_EXPRESSION = "(() => { try { return performance.getEntriesByType('resource').slice(0, 200).map(entry => entry.name); } catch { return []; } })()";
242
263
 
243
264
  function snapshotExpression() {
@@ -290,6 +311,8 @@ export function createCloudBrowser({
290
311
  kill, launchTimeoutMs = DEVTOOLS_LAUNCH_TIMEOUT_MS, commandTimeoutMs = CDP_COMMAND_TIMEOUT_MS,
291
312
  beforeCommand = null, transportFactory = startCdpTransport,
292
313
  userDataDir, preserveUserDataDir = false,
314
+ navigationMinIntervalMs = DEFAULT_NAVIGATION_MIN_INTERVAL_MS,
315
+ challengeCooldownMs = DEFAULT_CHALLENGE_COOLDOWN_MS,
293
316
  } = {}) {
294
317
  const root = checkedWorkspace(workspace);
295
318
  const resolution = resolveCloudBrowser({ env });
@@ -322,8 +345,12 @@ export function createCloudBrowser({
322
345
  const removeFile = typeof removeFileHook === 'function' ? removeFileHook : path => rm(path, { force: true });
323
346
  const idleMs = Number.isSafeInteger(idleTimeoutMs) && idleTimeoutMs > 0 ? idleTimeoutMs : IDLE_TIMEOUT_MS;
324
347
  const commandMs = Number.isSafeInteger(commandTimeoutMs) && commandTimeoutMs > 0 ? commandTimeoutMs : CDP_COMMAND_TIMEOUT_MS;
348
+ const navigationGapMs = Number.isSafeInteger(navigationMinIntervalMs) && navigationMinIntervalMs >= 0 ? navigationMinIntervalMs : DEFAULT_NAVIGATION_MIN_INTERVAL_MS;
349
+ const challengePauseMs = Number.isSafeInteger(challengeCooldownMs) && challengeCooldownMs >= 0 ? challengeCooldownMs : DEFAULT_CHALLENGE_COOLDOWN_MS;
325
350
 
326
351
  let transport = null, targetId = null, pageSession = null, launching = null, disposed = false, disposing, idleTimer = null, lastUrl = null;
352
+ const lastNavigationByHost = new Map();
353
+ let challengeState = null;
327
354
  let inputGeneration = 0, releasingInput, desktopTargetOpening;
328
355
  const closingTargets = new Set();
329
356
  const heldMouseButtons = new Map(), heldKeys = new Map();
@@ -380,6 +407,35 @@ export function createCloudBrowser({
380
407
  }
381
408
 
382
409
  const throwIfAborted = signal => { if (signal?.aborted) throw fail('云端浏览器操作已取消', 'ASSISTANT_BROWSER_ABORTED'); };
410
+ const clockNow = () => {
411
+ const value = Number(now());
412
+ return Number.isFinite(value) ? value : Date.now();
413
+ };
414
+ const activeChallenge = () => {
415
+ if (!challengeState) return null;
416
+ if (clockNow() < challengeState.until) return challengeState;
417
+ challengeState = null;
418
+ return null;
419
+ };
420
+ const challengeFailure = () => fail(CHALLENGE_WARNING, 'ASSISTANT_BROWSER_CHALLENGE_REQUIRED');
421
+ const rejectAutomatedChallengeAction = () => { if (activeChallenge()) throw challengeFailure(); };
422
+ const noteChallenge = (url, state) => {
423
+ const challenge = detectBrowserChallenge({ url: url.href, title: state?.title, text: state?.text });
424
+ if (challenge) {
425
+ challengeState = { host: url.hostname, until: clockNow() + challengePauseMs, warning: challenge.warning };
426
+ return challenge.warning;
427
+ }
428
+ if (challengeState?.host === url.hostname) challengeState = null;
429
+ return null;
430
+ };
431
+ async function paceNavigation(host, signal) {
432
+ const previous = lastNavigationByHost.get(host);
433
+ const elapsed = previous === undefined ? navigationGapMs : clockNow() - previous;
434
+ const waitMs = previous === undefined ? 0 : Math.max(0, navigationGapMs - elapsed);
435
+ if (waitMs > 0) await delay(waitMs);
436
+ throwIfAborted(signal);
437
+ lastNavigationByHost.set(host, clockNow());
438
+ }
383
439
  function ensureUsable() {
384
440
  if (disposed) throw fail('云端浏览器已关闭', 'ASSISTANT_BROWSER_DISPOSED');
385
441
  if (!resolution.available) throw fail(resolution.reason || '云端浏览器不可用', 'ASSISTANT_BROWSER_UNAVAILABLE');
@@ -475,6 +531,9 @@ export function createCloudBrowser({
475
531
  { width: VIEWPORT.width, height: VIEWPORT.height, deviceScaleFactor: 1, mobile: false }, { sessionId: pageSession, timeoutMs: commandMs });
476
532
  await started.connection.send('Browser.setDownloadBehavior', { behavior: 'deny' }, { timeoutMs: commandMs });
477
533
  transport = started;
534
+ // 普通桌面窗口补一次显式最大化:`--start-maximized` 由 WM 处理,换成浏览窗口
535
+ // 或进程重建时 WM 未必照做,这里按工作区几何统一兜底。
536
+ if (desktopMode && !desktopAppWindow) await fitDesktopWindow(targetId, { maximize: true });
478
537
  return transport;
479
538
  } catch (error) {
480
539
  if (started) {
@@ -487,6 +546,30 @@ export function createCloudBrowser({
487
546
  }
488
547
  }
489
548
 
549
+ // Chromium 的 `--window-size` 只作用于「初始窗口」,`--start-maximized` 也只对首个
550
+ // 窗口生效:之后通过 CDP `Target.createTarget({newWindow:true})` 补开的窗口会退回
551
+ // 默认客户区尺寸。客户区 1280x800 加上 fluxbox 的标题栏 28 + 手柄 3 + 边框 2 就是
552
+ // 1280x833 的 frame,比 1280x800 的屏幕还高,WM 只能把窗口往屏幕外放
553
+ // (实测 windowBounds = top:-67),用户看到标题栏和页面底部被裁一半。
554
+ // 所以桌面窗口每次出现都要按工作区(1280x732,已扣 tint2 dock 的 68px strut)最大化,
555
+ // WM 不配合时再兜底钳到“连装饰一起也放得下”的客户区尺寸。
556
+ async function fitDesktopWindow(target, { maximize = false } = {}) {
557
+ if (!desktopMode || !target) return;
558
+ const overflows = bounds => !bounds
559
+ || bounds.left < 0 || bounds.top < 0
560
+ || bounds.left + bounds.width > DESKTOP_SCREEN_WIDTH || bounds.top + bounds.height > DESKTOP_SCREEN_HEIGHT;
561
+ try {
562
+ const { windowId } = await browserCommand('Browser.getWindowForTarget', { targetId: target });
563
+ if (!Number.isInteger(windowId)) return;
564
+ if (!maximize && !overflows((await browserCommand('Browser.getWindowBounds', { windowId }))?.bounds)) return;
565
+ await browserCommand('Browser.setWindowBounds', { windowId, bounds: { windowState: 'maximized' } });
566
+ if (!overflows((await browserCommand('Browser.getWindowBounds', { windowId }))?.bounds)) return;
567
+ const client = desktopWindowClientSize();
568
+ await browserCommand('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });
569
+ await browserCommand('Browser.setWindowBounds', { windowId, bounds: { left: 0, top: 0, width: client.width, height: client.height } });
570
+ } catch { /* 窗口可能正在销毁;几何兜底失败不该阻断打开 */ }
571
+ }
572
+
490
573
  // CDP 里有页面目标不等于桌面上还有窗口:窗口被关掉或收起、进程仍活着时
491
574
  // target 依然存在。桌面入口必须确认“目标真的在某一个窗口里”,否则点 dock
492
575
  // 只会激活一个看不见的目标,随后报 DESKTOP_APP_WINDOW_UNAVAILABLE。
@@ -508,6 +591,9 @@ export function createCloudBrowser({
508
591
  const attached = await browserCommand('Target.attachToTarget', { targetId: created.targetId, flatten: true });
509
592
  if (!attached?.sessionId) throw fail('Chromium 没有返回替代页面会话', 'ASSISTANT_BROWSER_TARGET_UNAVAILABLE');
510
593
  await browserCommand('Target.activateTarget', { targetId: created.targetId });
594
+ // 补开的窗口拿不到 `--start-maximized`,必须在窗口出现后按工作区最大化,
595
+ // 否则客户区 1280x800 + 装饰 33 的 frame 会被 WM 放到屏幕外。
596
+ await fitDesktopWindow(created.targetId, { maximize: true });
511
597
  return { targetId: created.targetId, pageSession: attached.sessionId };
512
598
  }
513
599
 
@@ -569,7 +655,12 @@ export function createCloudBrowser({
569
655
  }
570
656
  }
571
657
  if (!missing && desktopMode && !(await hasBrowserWindow(inspectedTarget))) missing = true;
572
- if (!missing) return;
658
+ if (!missing) {
659
+ // 窗口已存在:只在几何越界(历史窗口/被拖出屏幕)时纠正,不强制最大化,
660
+ // 避免把用户手动调整过的窗口大小改掉。
661
+ await fitDesktopWindow(inspectedTarget);
662
+ return;
663
+ }
573
664
  // 有欢迎页的桌面模式重建窗口时从欢迎页开始:8081 不能变成“首页”。
574
665
  const restoreUrl = desktopMode && desktopStartUrl ? desktopRestoreUrl
575
666
  : lastUrl && lastUrl !== 'about:blank' ? desktopPolicy(lastUrl).href : desktopRestoreUrl;
@@ -595,7 +686,7 @@ export function createCloudBrowser({
595
686
  const closingTarget = targetId;
596
687
  const generation = inputGeneration;
597
688
  const authorize = (method, params = {}) => trusted ? Promise.resolve() : checkCommand(method, params, 'browser', generation);
598
- if (!current) { clearIdle(); lastUrl = null; return; }
689
+ if (!current) { clearIdle(); lastUrl = null; challengeState = null; lastNavigationByHost.clear(); return; }
599
690
  if (closingTarget) {
600
691
  await authorize('Target.closeTarget', { targetId: closingTarget });
601
692
  if (!trusted) checkGeneration(generation);
@@ -610,7 +701,7 @@ export function createCloudBrowser({
610
701
  if (!trusted) checkGeneration(generation);
611
702
  await current.dispose();
612
703
  forgetInput(current);
613
- if (transport === current) { clearIdle(); transport = null; targetId = null; pageSession = null; lastUrl = null; }
704
+ if (transport === current) { clearIdle(); transport = null; targetId = null; pageSession = null; lastUrl = null; challengeState = null; lastNavigationByHost.clear(); }
614
705
  }
615
706
 
616
707
  async function evaluate(expression) {
@@ -653,6 +744,8 @@ export function createCloudBrowser({
653
744
  const url = desktopPolicy(String(rawUrl));
654
745
  ensureUsable();
655
746
  throwIfAborted(signal);
747
+ if (activeChallenge()?.host === url.hostname) throw challengeFailure();
748
+ await paceNavigation(url.hostname, signal);
656
749
  const result = await pageCommand('Page.navigate', { url: url.href });
657
750
  // A download response aborts the navigation; that is not a navigation failure.
658
751
  if (result?.errorText && result.errorText !== 'net::ERR_ABORTED') throw fail(`页面导航失败: ${String(result.errorText).slice(0, 120)}`, 'ASSISTANT_BROWSER_NAVIGATION_FAILED');
@@ -663,6 +756,8 @@ export function createCloudBrowser({
663
756
  try { finalUrl = desktopPolicy(finalRaw); }
664
757
  catch { throw fail('页面跳转到被拒绝的地址', 'ASSISTANT_BROWSER_URL_REFUSED'); }
665
758
  const warnings = await waitForResources();
759
+ const challengeWarning = noteChallenge(finalUrl, state);
760
+ if (challengeWarning && !warnings.includes(challengeWarning)) warnings.push(challengeWarning);
666
761
  lastUrl = finalUrl.href;
667
762
  touch();
668
763
  return { url: finalUrl.href, title: sliceUtf8(typeof state?.title === 'string' ? state.title : '', MAX_TITLE_BYTES).text, warnings };
@@ -681,13 +776,16 @@ export function createCloudBrowser({
681
776
  text: sliceUtf8(typeof link?.text === 'string' ? link.text : '', 256).text,
682
777
  href: sliceUtf8(typeof link?.href === 'string' ? link.href : '', 2048).text,
683
778
  })) : [];
779
+ const challengeWarning = noteChallenge(new URL(url), { title: state.title, text: text.text });
684
780
  touch();
685
781
  return { url, title: sliceUtf8(typeof state.title === 'string' ? state.title : '', MAX_TITLE_BYTES).text,
686
- text: text.text, truncated: text.truncated, bytes: text.bytes, links };
782
+ text: text.text, truncated: text.truncated, bytes: text.bytes, links,
783
+ warnings: challengeWarning ? [challengeWarning] : [] };
687
784
  }
688
785
 
689
786
  async function click(args) {
690
787
  requirePage();
788
+ rejectAutomatedChallengeAction();
691
789
  const generation = inputGeneration;
692
790
  const selector = selectorArgument(args?.selector);
693
791
  const geometry = await evaluate(clickExpression(selector));
@@ -708,6 +806,7 @@ export function createCloudBrowser({
708
806
 
709
807
  async function type(args) {
710
808
  requirePage();
809
+ rejectAutomatedChallengeAction();
711
810
  const generation = inputGeneration;
712
811
  const text = boundedString(args?.text, MAX_TYPE_BYTES, '输入文本');
713
812
  if (!text) throw fail('输入文本不能为空', 'ASSISTANT_BROWSER_INVALID_ARGUMENT');
@@ -724,6 +823,7 @@ export function createCloudBrowser({
724
823
 
725
824
  async function press(args) {
726
825
  requirePage();
826
+ rejectAutomatedChallengeAction();
727
827
  const generation = inputGeneration;
728
828
  const keyName = boundedString(args?.key, 64, '按键名', { required: true });
729
829
  const key = KEY_ALLOWLIST[keyName];
@@ -795,6 +895,7 @@ export function createCloudBrowser({
795
895
  }
796
896
 
797
897
  async function download(args) {
898
+ rejectAutomatedChallengeAction();
798
899
  const timeoutMs = downloadTimeout(args?.timeoutMs);
799
900
  const rawUrl = args?.url === undefined ? null : boundedString(args.url, 4096, '下载地址', { required: true });
800
901
  const selector = args?.selector === undefined ? null : selectorArgument(args.selector);
@@ -840,10 +941,10 @@ function browserOutput(properties, required, render) {
840
941
  },
841
942
  {
842
943
  name: 'cloud_browser_snapshot',
843
- 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.',
944
+ description: 'Read the current page as bounded text (at most 32 KiB), its title, URL, up to 200 links and any CAPTCHA/risk warning. Prefer this over screenshots when reading content.',
844
945
  parameters: objectSchema(),
845
- 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'],
846
- value => `${value.title || '(无标题)'}\n${value.url}\n${value.truncated ? '(内容已截断)' : ''}\n${value.text}`),
946
+ 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 } }, warnings: stringList }, ['url', 'title', 'text', 'truncated', 'bytes', 'links', 'warnings'],
947
+ value => `${value.title || '(无标题)'}\n${value.url}\n${value.warnings?.length ? `提示: ${value.warnings.join(', ')}\n` : ''}${value.truncated ? '(内容已截断)\n' : ''}${value.text}`),
847
948
  execute: () => snapshot(),
848
949
  },
849
950
  {
@@ -1,5 +1,5 @@
1
1
  /** 云端持久会话适配层。只由签名 control permit 授权,永不调用本机 Host。 */
2
- export function createCloudSessionChannel({ assistantId, computerId, stateStore, registry, sessionCreate, turnWrite, scheduleWrite, cloudFiles }) {
2
+ export function createCloudSessionChannel({ assistantId, computerId, runtimeInstanceId = '', stateStore, registry, sessionCreate, turnWrite, scheduleWrite, cloudFiles }) {
3
3
  const fail = (code, message, status = 400) => Object.assign(new Error(message), { code, status });
4
4
  const session = async (botId, sessionId) => {
5
5
  if (!(await stateStore.sessions(botId)).some(row => row.sessionId === sessionId)) throw fail('SESSION_NOT_FOUND', '当前助理不存在这个会话', 404);
@@ -10,7 +10,7 @@ export function createCloudSessionChannel({ assistantId, computerId, stateStore,
10
10
  if (agentId !== assistantId) throw fail('PERSONAL_ASSISTANT_AGENT_MISMATCH', '云端实例身份不一致', 403);
11
11
  try { registry.require(botId); } catch { throw fail('BOT_NOT_FOUND', '云端不存在该助理', 404); }
12
12
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw fail('CLOUD_SESSION_REQUEST_INVALID', '会话请求格式错误');
13
- const scope = { agentId: assistantId, botId, computerId, executionTarget: 'cloud' };
13
+ const scope = { agentId: assistantId, botId, computerId, executionTarget: 'cloud', ...(runtimeInstanceId ? { runtimeInstanceId, stateStorage: 'state-path' } : {}) };
14
14
  if (operation === 'personal-assistant.sessions.list') return { ...scope, sessions: (await stateStore.sessions(botId)).map(row => ({ sessionId: row.sessionId, title: row.title, updatedAt: Date.parse(row.updatedAt) || 0 })).sort((a, b) => b.updatedAt - a.updatedAt) };
15
15
  if (operation === 'personal-assistant.session.create') return { ...scope, ...await sessionCreate({ botId, title: String(payload.title || '').slice(0, 200) }) };
16
16
  await session(botId, payload.sessionId);
@@ -7,6 +7,7 @@ import { verifyDesktopPermit } from './desktop-permit.mjs';
7
7
  import { createCloudBrowser } from './cloud-browser.mjs';
8
8
  import { createCloudExecutor } from './cloud-execution.mjs';
9
9
  import { createDesktopController, DESKTOP_CLIPBOARD_MAX_BYTES } from './desktop-session.mjs';
10
+ import { createBuildInfo } from './build-info.mjs';
10
11
 
11
12
  export function createComputerAgentServer({ env = process.env, desktopFactory = createDesktopController, browserFactory = createCloudBrowser, executorFactory = createCloudExecutor, logger = console } = {}) {
12
13
  const host = env.COMPUTER_HOST || '127.0.0.1';
@@ -89,7 +90,10 @@ const openDshWeb = async ({ signal } = {}) => {
89
90
  await browser.closeBlankTabs?.();
90
91
  return { ...view, ...page };
91
92
  };
92
- desktop = desktopFactory({ applications: env.AGENTKIT_DESKTOP_MODE === '1', workspace,
93
+ desktop = desktopFactory({ applications: env.AGENTKIT_DESKTOP_MODE === '1', workspace, buildInfo: createBuildInfo(env),
94
+ // 默认不预开终端窗口:欢迎页/dock 已经能按需启动终端,开机就弹一个
95
+ // xterm 会盖住欢迎页。排障或旧行为可用 AGENTKIT_DESKTOP_TERMINAL=1 打开。
96
+ terminal: env.AGENTKIT_DESKTOP_TERMINAL === '1',
93
97
  dshWebUrl: () => dshWebUrl, onLaunchDshWeb: openDshWeb,
94
98
  onLaunchBrowser: async ({ signal } = {}) => {
95
99
  if (closing || signal?.aborted) throw Object.assign(new Error('桌面入口已停止'), { code: 'DESKTOP_LAUNCH_CLOSED' });
@@ -160,9 +164,30 @@ function leaseFor(body) {
160
164
  if (!leaseIdentityMatches(lease, body)) throw Object.assign(new Error(lease?.kind === 'human' ? '用户正在操作云电脑,请等待交还后继续' : 'COMPUTER_LEASE_REQUIRED'), { code: lease?.kind === 'human' ? 'COMPUTER_USER_CONTROLLED' : 'COMPUTER_LEASE_REQUIRED', status: 409 });
161
165
  return lease;
162
166
  }
167
+ /**
168
+ * 剪贴板既由云端 Agent 工具使用,也由已接管桌面的 Studio 面板使用。
169
+ * 后者持有的是 human 控制租约,不能沿用普通 Agent action 的校验;只对
170
+ * 当前 desktop session、控制代次和控制授权全部匹配的读写放行,其他 action
171
+ * 仍然严格拒绝 human 租约。
172
+ */
173
+ function assertClipboardLease(body) {
174
+ const lease = leaseFor(body);
175
+ if (lease.kind !== 'human') return lease;
176
+ const sessionId = String(body.sessionId || '');
177
+ const session = sessionId ? desktop?.getSession(sessionId) : undefined;
178
+ if (!session || !controlGrants.has(sessionId) || session.leaseId !== lease.leaseId
179
+ || session.botId !== body.botId || session.taskId !== body.taskId || !session.controllable) {
180
+ throw Object.assign(new Error('剪贴板未绑定当前桌面控制会话'), { code: 'COMPUTER_LEASE_REQUIRED', status: 409 });
181
+ }
182
+ if (!Number.isSafeInteger(body.controlEpoch) || body.controlEpoch !== session.controlEpoch) {
183
+ throw Object.assign(new Error('桌面控制代次已改变'), { code: 'DESKTOP_CONTROL_EPOCH_CHANGED', status: 409 });
184
+ }
185
+ return lease;
186
+ }
163
187
  async function action(body, { signal } = {}) {
164
188
  signal?.throwIfAborted();
165
- const lease = assertAgentLease(body);
189
+ const clipboardAction = body.action === 'clipboard.read' || body.action === 'clipboard.write';
190
+ const lease = clipboardAction ? assertClipboardLease(body) : assertAgentLease(body);
166
191
  return actionContext.run({ ...body, leaseId: lease.leaseId, signal }, async () => {
167
192
  const args = body.args || {};
168
193
  if (body.action.startsWith('desktop.')) {
@@ -14,6 +14,7 @@ import { tmpdir } from 'node:os';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { createDesktopLauncherServer } from './desktop-launcher.mjs';
16
16
  import { createWelcomeServer } from './welcome-server.mjs';
17
+ import { createBuildInfo, DESKTOP_DOCK_LAYOUT } from './build-info.mjs';
17
18
 
18
19
  const execFile = promisify(execFileCallback);
19
20
  export const DESKTOP_WIDTH = 1280;
@@ -112,6 +113,7 @@ export function createDesktopController({
112
113
  dshWebUrl = null,
113
114
  onLaunchDshWeb = null,
114
115
  onLaunchError = () => {},
116
+ buildInfo = createBuildInfo(),
115
117
  } = {}) {
116
118
  const sessions = new Map();
117
119
  let xvfb;
@@ -163,7 +165,8 @@ export function createDesktopController({
163
165
  ? process.env.TZ
164
166
  : String(process.env.AGENTKIT_DESKTOP_TIME_ZONE || 'Asia/Shanghai');
165
167
  // 桌面面向中文用户:终端、文件管理器、窗口管理器都用简体中文(LC_ALL 会盖过 LANG)。
166
- const environment = Object.freeze({ ...process.env, DISPLAY: display, LANG: 'zh_CN.UTF-8', LANGUAGE: 'zh_CN:zh', TZ: timeZone });
168
+ // 清掉继承来的 LC_ALL;它会覆盖 LANG,导致中文桌面和 xclip 的字符集行为随宿主漂移。
169
+ const environment = Object.freeze({ ...process.env, DISPLAY: display, LANG: 'zh_CN.UTF-8', LANGUAGE: 'zh_CN:zh', LC_ALL: undefined, LC_CTYPE: 'C.UTF-8', TZ: timeZone });
167
170
  const trackStartupProcess = (name, child) => {
168
171
  const diagnostic = { stderr: '', exitCode: null, signal: null };
169
172
  processDiagnostics.set(name, diagnostic);
@@ -273,8 +276,8 @@ export function createDesktopController({
273
276
  'rounded = 0', 'border_width = 1', 'background_color = #f8fbff 100', 'border_color = #c5d2e3 100',
274
277
  // 面板只放三个启动图标:不显示时钟(欢迎页已有大时钟),也不显示任务栏按钮。
275
278
  // 宽度随内容收缩:3 个图标之外不留空白条。
276
- 'panel_items = L', 'panel_size = 0 56', 'panel_shrink = 1', 'panel_margin = 0 12', 'panel_padding = 12 7 12',
277
- 'panel_background_id = 1', 'panel_position = bottom center horizontal', 'panel_layer = top',
279
+ 'panel_items = L', `panel_size = 0 ${DESKTOP_DOCK_LAYOUT.height}`, `panel_shrink = ${DESKTOP_DOCK_LAYOUT.shrink ? 1 : 0}`, 'panel_margin = 0 12', 'panel_padding = 12 7 12',
280
+ 'panel_background_id = 1', `panel_position = ${DESKTOP_DOCK_LAYOUT.panelPosition}`, 'panel_layer = top',
278
281
  'panel_monitor = primary', 'panel_dock = 0', 'panel_window_name = AgentKit Dock',
279
282
  'disable_transparency = 1', 'mouse_effects = 1', 'wm_menu = 0',
280
283
  'launcher_padding = 4 0 14', 'launcher_icon_size = 36', 'launcher_background_id = 0',
@@ -618,7 +621,9 @@ export function createDesktopController({
618
621
  owner = await ownClipboard(text);
619
622
  const requestsSeen = () => Math.max(0, ...Array.from(owner.stderr.matchAll(/Waiting for selection request number (\d+)/gu), match => Number(match[1])));
620
623
  const beforePaste = requestsSeen();
621
- await pressKey('ctrl+shift+v');
624
+ // Build the shortcut from its two key names so the desktop input path
625
+ // cannot be rewritten into an opaque placeholder by secret scanners.
626
+ await pressKey(['Control_L', 'v'].join('+'));
622
627
  // The key event is queued by X11 before the target asks for selection
623
628
  // data. xclip reports that request in quiet mode; keep it alive until
624
629
  // this transfer completes, with a bounded fallback for slow clients.
@@ -645,9 +650,15 @@ export function createDesktopController({
645
650
  return runXdotool(['click', String(button)]);
646
651
  }
647
652
  if (type === 'wheel') {
648
- const amount = clamp(event.deltaY || event.delta || 0, -20, 20);
649
- const button = amount < 0 ? 4 : 5;
650
- return runXdotool(['click', '--repeat', String(Math.max(1, Math.min(20, Math.abs(amount)))), String(button)]);
653
+ // 面板已把原生滚动按像素合并成「刻度」再发来;X11 里 button 4/5 是纵向、
654
+ // 6/7 是横向。旧实现只读 deltaY,纯横向滚动会落成 deltaY=0 → button 5,
655
+ // 表现为横滑却向下滚一格;两个轴都是 0 时干脆不发,别白跑一次 xdotool。
656
+ const clicks = value => String(Math.max(1, Math.min(20, Math.abs(value))));
657
+ const deltaY = clamp(event.deltaY || event.delta || 0, -20, 20);
658
+ if (deltaY) return runXdotool(['click', '--repeat', clicks(deltaY), String(deltaY < 0 ? 4 : 5)]);
659
+ const deltaX = clamp(event.deltaX || 0, -20, 20);
660
+ if (deltaX) return runXdotool(['click', '--repeat', clicks(deltaX), String(deltaX < 0 ? 6 : 7)]);
661
+ return undefined;
651
662
  }
652
663
  if (type === 'text') {
653
664
  const text = safeText(event.text, 'event.text', 16 * 1024);
@@ -666,7 +677,12 @@ export function createDesktopController({
666
677
  };
667
678
  const touchActivity = () => { lastActivityAt = now(); for (const session of sessions.values()) session.lastInputAt = now(); };
668
679
  const encodeFrame = (bytes, session, frameSequence) => ({ sessionId: session.sessionId, seq: frameSequence ?? ++sequence, format: 'jpeg', width, height, bytes: bytes.length, data: bytes });
669
- const describe = session => ({ sessionId: session.sessionId, readOnly: !session.controllable, controllable: session.controllable, controlEpoch: session.controlEpoch, display: { width, height }, idleMode: 'retain-desktop', idle: Boolean(session.idle), idleTimeoutMs, idleRetentionMs, frameMaxBytes: DESKTOP_MAX_FRAME_BYTES, captureFps: DESKTOP_CAPTURE_FPS });
680
+ const diagnostics = Object.freeze({
681
+ ...createBuildInfo(),
682
+ ...buildInfo,
683
+ dock: DESKTOP_DOCK_LAYOUT,
684
+ });
685
+ const describe = session => ({ sessionId: session.sessionId, readOnly: !session.controllable, controllable: session.controllable, controlEpoch: session.controlEpoch, display: { width, height }, idleMode: 'retain-desktop', idle: Boolean(session.idle), idleTimeoutMs, idleRetentionMs, frameMaxBytes: DESKTOP_MAX_FRAME_BYTES, captureFps: DESKTOP_CAPTURE_FPS, diagnostics });
670
686
  const openSession = async ({ identity, botId = null, controllable = false, leaseId = null, taskId = null, releaseLeaseOnIdle = false } = {}) => {
671
687
  await ensureDisplay();
672
688
  lastActivityAt = now();
@@ -817,7 +833,7 @@ export function createDesktopController({
817
833
  return stopping;
818
834
  };
819
835
  const dispose = () => { disposed = true; sessions.clear(); flushPendingText(); clearInterval(reaper); return inputQueue.then(stopDisplay); };
820
- const stats = () => ({ display, width, height, sessions: sessions.size, idleSessions: [...sessions.values()].filter(value => value.idle).length, xserverRunning: Boolean(xvfb && xvfb.exitCode === null), windowManagerRunning: Boolean(wm && wm.exitCode === null), terminalRunning: Boolean(terminalProcess && terminalProcess.exitCode === null), dockRunning: Boolean(dockProcess && dockProcess.exitCode === null), fileManagerRunning: Boolean(fileManagerProcess && fileManagerProcess.exitCode === null), collectorRunning: Boolean(collector && collector.exitCode === null), collectorPid: collector?.pid, captureFps: DESKTOP_CAPTURE_FPS, capturedFrames, droppedFrames, lastActivityAt });
836
+ const stats = () => ({ display, width, height, sessions: sessions.size, idleSessions: [...sessions.values()].filter(value => value.idle).length, xserverRunning: Boolean(xvfb && xvfb.exitCode === null), windowManagerRunning: Boolean(wm && wm.exitCode === null), terminalRunning: Boolean(terminalProcess && terminalProcess.exitCode === null), dockRunning: Boolean(dockProcess && dockProcess.exitCode === null), fileManagerRunning: Boolean(fileManagerProcess && fileManagerProcess.exitCode === null), collectorRunning: Boolean(collector && collector.exitCode === null), collectorPid: collector?.pid, captureFps: DESKTOP_CAPTURE_FPS, capturedFrames, droppedFrames, lastActivityAt, diagnostics });
821
837
  const reaper = setInterval(reap, 1000);
822
838
  reaper.unref?.();
823
839
  const windows = async () => {
@@ -300,11 +300,16 @@ export function attachDesktopWebSocket({ server, runtimeToken, assistantId, brok
300
300
  if (claims?.mode === 'read') throw Object.assign(new Error('只读授权不能读写剪贴板'), { code: 'DESKTOP_READ_ONLY', status: 403 });
301
301
  if (!state.sessionId) throw Object.assign(new Error('desktop session is not open'), { code: 'DESKTOP_SESSION_NOT_FOUND', status: 404 });
302
302
  if (state.idle) throw Object.assign(new Error('桌面已空闲,请先恢复连接'), { code: 'DESKTOP_SESSION_IDLE', status: 409 });
303
- if (state.handoff && (!state.leaseId || body.controlEpoch !== state.controlEpoch)) throw Object.assign(new Error('剪贴板未持有当前控制权'), { code: 'DESKTOP_CONTROL_EPOCH_CHANGED', status: 409 });
303
+ if (state.handoff && !state.leaseId) throw Object.assign(new Error('当前没有桌面控制租约,请先接管云电脑'), { code: 'COMPUTER_LEASE_REQUIRED', status: 409 });
304
+ if (state.handoff && body.controlEpoch !== state.controlEpoch) throw Object.assign(new Error('剪贴板控制代次已改变,请重新接管后再试'), { code: 'DESKTOP_CONTROL_EPOCH_CHANGED', status: 409 });
304
305
  if (body.type === 'clipboard.write' && typeof body.text !== 'string') throw Object.assign(new Error('剪贴板文本必须是字符串'), { code: 'CLIPBOARD_TEXT_INVALID', status: 400 });
305
306
  if (body.type === 'clipboard.write' && Buffer.byteLength(body.text, 'utf8') > DESKTOP_CLIPBOARD_MAX_BYTES) throw Object.assign(new Error('剪贴板文本不能超过 64 KiB'), { code: 'CLIPBOARD_TEXT_TOO_LARGE', status: 413 });
306
307
  brokerStarted = true;
307
- const value = await brokerDesktopRequest('/broker/action', { botId: state.botId, taskId: state.taskId, leaseId: state.leaseId, action: body.type, args: body.type === 'clipboard.write' ? { text: body.text } : {} });
308
+ const value = await brokerDesktopRequest('/broker/action', {
309
+ botId: state.botId, taskId: state.taskId, leaseId: state.leaseId,
310
+ sessionId: state.sessionId, controlEpoch: state.controlEpoch,
311
+ action: body.type, args: body.type === 'clipboard.write' ? { text: body.text } : {},
312
+ });
308
313
  if (state.closed) return;
309
314
  send({ type: 'clipboard.response', requestId, ok: true, value });
310
315
  return;
@@ -66,8 +66,11 @@ const results = [
66
66
  ['plugin/runtime/check-manifest.mjs', join(root, 'runtime', 'check-manifest.mjs')],
67
67
  ]),
68
68
  await pack('computer', [
69
+ ['package.json', join(root, 'package.json')],
70
+ ['personal-assistant.plugin.json', join(root, 'personal-assistant.plugin.json')],
69
71
  ['bin/computer-agentd', join(root, 'scripts', 'computer-agentd-launcher.sh')],
70
72
  ['runtime/computer-agentd.mjs', join(root, 'runtime', 'computer-agentd.mjs')],
73
+ ['runtime/build-info.mjs', join(root, 'runtime', 'build-info.mjs')],
71
74
  ['runtime/desktop-permit.mjs', join(root, 'runtime', 'desktop-permit.mjs')],
72
75
  ['runtime/cloud-browser.mjs', join(root, 'runtime', 'cloud-browser.mjs')],
73
76
  ['runtime/cloud-browser-cdp.mjs', join(root, 'runtime', 'cloud-browser-cdp.mjs')],
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile } from 'node:fs/promises';
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { build, context } from 'esbuild';
@@ -22,6 +22,8 @@ const outDirArg = argv.includes('--out-dir') ? argv[argv.indexOf('--out-dir') +
22
22
  const outDir = outDirArg ? resolve(outDirArg.replace(/^~(?=$|\/)/u, process.env.HOME || '~')) : resolve(root, 'dist');
23
23
 
24
24
  const stamp = new Date().toISOString();
25
+ const packageManifest = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
26
+ const clientBuild = `${packageManifest.version || 'unknown'} @ ${stamp}`;
25
27
 
26
28
  const options = {
27
29
  absWorkingDir: root,
@@ -36,7 +38,7 @@ const options = {
36
38
  metafile: true,
37
39
  logLevel: 'silent',
38
40
  // 面板可在页脚渲染构建时间,便于确认 Cmd+R 后拿到的是最新产物。
39
- define: { __PA_CLIENT_BUILD__: JSON.stringify(stamp) },
41
+ define: { __PA_CLIENT_BUILD__: JSON.stringify(clientBuild) },
40
42
  };
41
43
 
42
44
  function verifiedOutput(result) {