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

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 +733 -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 +281 -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,127 @@
1
+ /**
2
+ * 云端 DSH 网页入口桥。
3
+ *
4
+ * 云端 DSH 网页(`dsh web`)只监听同一 Pod 的 127.0.0.1:8081,且必须带启动时生成的
5
+ * token。云桌面里的浏览器和宿主共享同一个网络命名空间,因此地址本身可达,缺的只是
6
+ * token。这里做两件事:
7
+ * 1) 从本机 DSH 启动日志里读出这一次的网页地址(token 只在这个 Pod 内使用);
8
+ * 2) 把地址推送给云电脑的 desktop agentd(`dsh.web` 动作),由它交给欢迎页桥。
9
+ *
10
+ * 边界:
11
+ * - token 不落盘、不写日志:只在内存里传给 agentd;日志只记录 host/port。
12
+ * - 只接受本机回环地址,避免把外网地址塞进云桌面。
13
+ * - 推送失败不阻塞宿主就绪,由调用方在后台重试;地址变化(DSH 重启)会再推一次。
14
+ */
15
+
16
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
17
+ const TOKEN_URL_PATTERN = /https?:\/\/127\.0\.0\.1:(\d+)\/\?token=([A-Za-z0-9._~+\/-]{8,})/u;
18
+
19
+ /** 从一段日志文本里取出本机 DSH 网页地址;没有就返回 null。 */
20
+ export function extractDshWebUrl(text) {
21
+ if (typeof text !== 'string' || !text) return null;
22
+ const match = TOKEN_URL_PATTERN.exec(text);
23
+ if (!match) return null;
24
+ const url = match[0];
25
+ try {
26
+ const parsed = new URL(url);
27
+ if (!LOOPBACK_HOSTS.has(parsed.hostname)) return null;
28
+ return parsed.href;
29
+ } catch { return null; }
30
+ }
31
+
32
+ /** 只允许本机回环 http(s) 地址。 */
33
+ export function isLoopbackDshWebUrl(value) {
34
+ if (typeof value !== 'string' || !value || Buffer.byteLength(value) > 512) return false;
35
+ try {
36
+ const parsed = new URL(value);
37
+ return ['http:', 'https:'].includes(parsed.protocol) && LOOPBACK_HOSTS.has(parsed.hostname);
38
+ } catch { return false; }
39
+ }
40
+
41
+ /**
42
+ * 读运行容器 /tmp 里的地址文件(entrypoint 从 DSH 启动输出里摘出来,权限 600,容器重启即失效)。
43
+ * 这是首选来源;日志解析只是没有该文件时的兜底。
44
+ */
45
+ export async function readDshWebUrlFile({ file, readFile = null } = {}) {
46
+ const target = file || process.env.AGENTKIT_DSH_WEB_URL_FILE || '/tmp/agentkit-runtime/dsh-web-url';
47
+ if (typeof target !== 'string' || !target) return null;
48
+ try {
49
+ const read = readFile || (await import('node:fs/promises')).readFile;
50
+ const text = await read(target, 'utf8');
51
+ const line = String(text).split('\n').map(item => item.trim()).find(item => item.startsWith('http://') || item.startsWith('https://'));
52
+ return isLoopbackDshWebUrl(line) ? new URL(line).href : null;
53
+ } catch { return null; }
54
+ }
55
+
56
+ /**
57
+ * 读最新的 DSH 启动日志,找这一步用的网页地址。
58
+ * 日志文件名形如 startup-<iso>-<uuid>.log,按修改时间取最新一条即可。
59
+ */
60
+ export async function findDshWebUrl({ dshHome, readdir, stat, readFile } = {}) {
61
+ if (typeof dshHome !== 'string' || !dshHome) return null;
62
+ const dir = `${dshHome.replace(/\/+$/u, '')}/logs`;
63
+ let names;
64
+ try { names = await readdir(dir); } catch { return null; }
65
+ const candidates = (Array.isArray(names) ? names : []).filter(name => /^startup-.*\.log$/u.test(name));
66
+ if (candidates.length === 0) return null;
67
+ const withTimes = await Promise.all(candidates.map(async name => {
68
+ try { return { name, at: (await stat(`${dir}/${name}`)).mtimeMs || 0 }; } catch { return null; }
69
+ }));
70
+ const newest = withTimes.filter(Boolean).sort((left, right) => right.at - left.at)[0];
71
+ if (!newest) return null;
72
+ let text;
73
+ try { text = await readFile(`${dir}/${newest.name}`, 'utf8'); } catch { return null; }
74
+ return extractDshWebUrl(text);
75
+ }
76
+
77
+ /** 把网页地址推给云电脑 agentd;返回 {published:true} 或抛错。 */
78
+ export async function publishDshWebUrl({ brokerUrl, url, fetchImpl = globalThis.fetch, signal } = {}) {
79
+ if (!isLoopbackDshWebUrl(url)) throw Object.assign(new Error('云端助理地址必须是本机回环地址'), { code: 'DSH_WEB_URL_INVALID' });
80
+ if (typeof brokerUrl !== 'string' || !brokerUrl) throw Object.assign(new Error('缺少云电脑地址'), { code: 'COMPUTER_BROKER_UNAVAILABLE' });
81
+ if (typeof fetchImpl !== 'function') throw Object.assign(new Error('缺少 fetch 实现'), { code: 'COMPUTER_BROKER_UNAVAILABLE' });
82
+ const target = new URL('/broker/dsh-web', brokerUrl.endsWith('/') ? brokerUrl : `${brokerUrl}/`);
83
+ const response = await fetchImpl(target, {
84
+ method: 'POST',
85
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
86
+ body: JSON.stringify({ protocol: 'personal-assistant.computer-broker/v1', url }),
87
+ ...(signal ? { signal } : {}),
88
+ });
89
+ const body = await response.json().catch(() => ({}));
90
+ if (!response.ok) throw Object.assign(new Error(body.message || body.error || `云电脑拒绝了云端助理地址 (${response.status})`), { code: body.error || 'COMPUTER_BROKER_FAILED', status: response.status });
91
+ return { published: true, url };
92
+ }
93
+
94
+ /**
95
+ * 后台把地址推给云电脑:日志可能比宿主就绪晚出现,所以隔一会儿重试几次;
96
+ * 地址不变就只推一次。任何失败都只记日志,不影响宿主启动。
97
+ */
98
+ export function startDshWebBridge({ env = process.env, brokerUrl, intervalMs = 5000, attempts = 24, timers = { setTimeout, clearTimeout }, logger = console, findUrl = findDshWebUrl, publish = publishDshWebUrl } = {}) {
99
+ const endpoint = brokerUrl || env.COMPUTER_BROKER_URL || env.AGENTKIT_COMPUTER_URL;
100
+ const dshHome = env.DSH_HOME;
101
+ let stopped = false;
102
+ let timer = null;
103
+ let lastPublished = null;
104
+ let remaining = attempts;
105
+ const run = async () => {
106
+ if (stopped) return;
107
+ try {
108
+ const url = await readDshWebUrlFile({ file: env.AGENTKIT_DSH_WEB_URL_FILE })
109
+ || await findUrl({ dshHome, readdir: (await import('node:fs/promises')).readdir, stat: (await import('node:fs/promises')).stat, readFile: (await import('node:fs/promises')).readFile });
110
+ if (url && url !== lastPublished) {
111
+ await publish({ brokerUrl: endpoint, url });
112
+ lastPublished = url;
113
+ logger.info(JSON.stringify({ event: 'personal-assistant.dsh-web.registered', host: new URL(url).hostname, port: new URL(url).port }));
114
+ return;
115
+ }
116
+ if (url && url === lastPublished) return;
117
+ } catch (error) {
118
+ logger.warn(JSON.stringify({ event: 'personal-assistant.dsh-web.unavailable', code: error?.code || 'DSH_WEB_LOOKUP_FAILED' }));
119
+ }
120
+ remaining -= 1;
121
+ if (remaining <= 0 || stopped) return;
122
+ timer = timers.setTimeout(() => { void run(); }, intervalMs);
123
+ timer?.unref?.();
124
+ };
125
+ void run();
126
+ return Object.freeze({ stop() { stopped = true; if (timer) timers.clearTimeout(timer); }, get published() { return lastPublished; } });
127
+ }
@@ -0,0 +1,281 @@
1
+ /** 通过 DSH 已鉴权 Connection RPC 转接到固定云网关;凭据始终留在 Host。 */
2
+ import { createRequire } from 'node:module';
3
+ import { DIRECTORY_BOT_ID, publicBotDirectory } from './client/bot-directory.mjs';
4
+ import { createCloudSessionTransport } from './cloud-session-transport.mjs';
5
+ /**
6
+ * 云网关会拒掉 Node 内置(undici)WebSocket:握手成功后立即 1006 断开且不带关闭帧,
7
+ * 面板只能看到「连接暂时不可用」。实测同一张 ticket 下浏览器、Python websockets 与 `ws`
8
+ * 包均正常,因此这里优先解析 Node 原生 `ws`(DSH Host 自带),再回退全局 WebSocket。
9
+ * 解析顺序:DSH Host 入口所在包 → 插件所在包及其上级 → 云端固定安装路径。
10
+ */
11
+ const resolveDefaultWebSocket = () => {
12
+ for (const base of [process.argv[1], import.meta.url, new URL('../../', import.meta.url).href, '/opt/agentkit/dsh-runtime/node_modules/']) {
13
+ if (!base) continue;
14
+ try {
15
+ const loaded = createRequire(base)('ws');
16
+ const Impl = loaded?.WebSocket ?? loaded?.default ?? loaded;
17
+ if (typeof Impl === 'function') return Impl;
18
+ } catch {}
19
+ }
20
+ return globalThis.WebSocket;
21
+ };
22
+ const DEFAULT_WEBSOCKET = resolveDefaultWebSocket();
23
+ const ENDPOINT = '/api/personal-assistant';
24
+ // 所有会话操作必须经过云端绑定和具名 Bot 的签名 control 票据。
25
+ const SESSION_OPERATIONS = Object.freeze([
26
+ 'personal-assistant.sessions.list',
27
+ 'personal-assistant.session.create',
28
+ 'personal-assistant.session.snapshot',
29
+ 'personal-assistant.session.events',
30
+ 'personal-assistant.turn.submit',
31
+ 'personal-assistant.schedule.create',
32
+ 'personal-assistant.file.upload',
33
+ 'personal-assistant.file.download',
34
+ ]);
35
+ const BINDING_OPERATIONS = Object.freeze([
36
+ // Studio owns the cloud account, the instance selection and the credential.
37
+ // These operations never accept a credential from the page: they only drive
38
+ // discovery, credential recovery and creation inside the Studio Host.
39
+ 'personal-assistant.desktop.status',
40
+ 'personal-assistant.desktop.connect',
41
+ 'personal-assistant.desktop.create',
42
+ 'personal-assistant.desktop.disconnect',
43
+ ]);
44
+ const BINDING_METHODS = Object.freeze({
45
+ 'personal-assistant.desktop.status': 'status',
46
+ 'personal-assistant.desktop.connect': 'connect',
47
+ 'personal-assistant.desktop.create': 'create',
48
+ 'personal-assistant.desktop.disconnect': 'disconnect',
49
+ });
50
+ const AGENT_ID = /^ar-[A-Za-z0-9-]{4,120}$/u;
51
+ const OPERATIONS = new Set(['personal-assistant.configuration', 'personal-assistant.bots.list', 'personal-assistant.desktop.ticket', ...BINDING_OPERATIONS, ...SESSION_OPERATIONS]);
52
+ const fail = (code, message, status = 400) => Object.assign(new Error(message), { code, status });
53
+ const plain = value => value && typeof value === 'object' && !Array.isArray(value);
54
+ const safeCloseCode = value => Number.isInteger(value) && value >= 1000 && value <= 4999 ? value : undefined;
55
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
56
+ const requireSessionId = value => {
57
+ const text = String(value ?? '').trim();
58
+ if (!SESSION_ID.test(text)) throw fail('PERSONAL_ASSISTANT_REQUEST_INVALID', '会话标识无效');
59
+ return text;
60
+ };
61
+ import { createSystemFetch, trustedCAs } from './system-fetch.mjs';
62
+
63
+ export const STUDIO_DESKTOP_SERVICE = 'studioPersonalAssistantDesktop';
64
+
65
+ // 默认走系统信任链:内网/预发网关的证书不在 Node 内置 CA 里,直接 fetch 会 "fetch failed"。
66
+ const defaultFetchImpl = createSystemFetch();
67
+ export function createPersonalAssistantHostGateway(config = {}, { env = process.env, fetchImpl = defaultFetchImpl, WebSocketImpl = DEFAULT_WEBSOCKET, getStudioDesktop = () => undefined, sessionChannel, cloudSessionTransport } = {}) {
68
+ const agentId = String(config.agentId ?? env.AGENTKIT_PERSONAL_ASSISTANT_AGENT_ID ?? '').trim();
69
+ const rawUrl = String(config.gatewayUrl ?? env.AGENTKIT_PERSONAL_ASSISTANT_GATEWAY_URL ?? '').trim();
70
+ const apiKey = String(config.apiKey ?? env.AGENTKIT_PERSONAL_ASSISTANT_GATEWAY_API_KEY ?? '').trim();
71
+ let base;
72
+ if (rawUrl) {
73
+ try {
74
+ base = new URL(rawUrl.endsWith('/') ? rawUrl : `${rawUrl}/`);
75
+ const local = ['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname);
76
+ const allowHttp = config.allowInsecureHttp === true || env.AGENTKIT_PERSONAL_ASSISTANT_ALLOW_HTTP_GATEWAY === '1';
77
+ if (base.username || base.password || base.search || base.hash || base.pathname !== '/' || (base.protocol !== 'https:' && !(base.protocol === 'http:' && (local || allowHttp)))) throw new Error('untrusted URL');
78
+ } catch { throw fail('PERSONAL_ASSISTANT_GATEWAY_INVALID', '云网关地址无效;预发 HTTP 需由 Host 显式允许'); }
79
+ }
80
+ const configured = Boolean(base && agentId && apiKey);
81
+ let studioSeen = false;
82
+ // 服务可晚于本插件加载,也可随 Studio 卸载;不缓存凭据或实例绑定。
83
+ async function callStudio(service, method, input, signal) {
84
+ try { return await service[method](input, signal || AbortSignal.timeout(10_000)); }
85
+ catch (error) {
86
+ const rawStatus = Number(error?.status ?? error?.details?.status);
87
+ const status = Number.isInteger(rawStatus) && rawStatus >= 400 && rawStatus <= 599 ? rawStatus : 503;
88
+ // 原始异常可能包含上游 HTTP 头或凭据,不能进入页面 RPC。
89
+ throw fail(status >= 500 ? 'PERSONAL_ASSISTANT_STUDIO_UNAVAILABLE' : 'PERSONAL_ASSISTANT_TICKET_REFUSED', 'Studio 暂时无法授权云电脑连接,请检查实例状态与云端登录', status);
90
+ }
91
+ }
92
+ async function binding(signal) {
93
+ let studio;
94
+ try { studio = getStudioDesktop(); }
95
+ catch { throw fail('PERSONAL_ASSISTANT_STUDIO_UNAVAILABLE', 'Studio 云连接服务暂时不可用', 503); }
96
+ if (studio != null) {
97
+ studioSeen = true;
98
+ if (typeof studio.configuration !== 'function' || typeof studio.issueTicket !== 'function') throw fail('PERSONAL_ASSISTANT_STUDIO_UNAVAILABLE', '请更新 Studio 云连接服务', 503);
99
+ const value = await callStudio(studio, 'configuration', agentId ? { agentId } : {}, signal);
100
+ if (!plain(value) || typeof value.configured !== 'boolean' || (value.configured && (typeof value.agentId !== 'string' || !/^[A-Za-z0-9._:-]{1,256}$/u.test(value.agentId)))) throw fail('PERSONAL_ASSISTANT_STUDIO_UNAVAILABLE', 'Studio 返回的实例绑定无效', 502);
101
+ return { configured: value.configured, agentId: value.configured ? value.agentId : undefined, studio };
102
+ }
103
+ if (studioSeen) throw fail('PERSONAL_ASSISTANT_STUDIO_UNAVAILABLE', 'Studio 云连接服务已卸载,请重新打开 Studio', 503);
104
+ // 独立开发 Host 的显式连接仍可用;Studio 拒绝/卸载时绝不降级到此分支。
105
+ return { configured, agentId, studio: undefined };
106
+ }
107
+ function publicStudioTicket(value, input) {
108
+ let ws;
109
+ try { ws = new URL(value?.wsUrl); } catch {}
110
+ const local = ws && ['127.0.0.1', 'localhost', '[::1]'].includes(ws.hostname);
111
+ const allowHttp = config.allowInsecureHttp === true || env.AGENTKIT_PERSONAL_ASSISTANT_ALLOW_HTTP_GATEWAY === '1';
112
+ const remaining = Date.parse(value?.expiresAt) - Date.now();
113
+ if (!plain(value) || !/^[A-Za-z0-9_-]{32,512}$/u.test(String(value.ticket || ''))
114
+ || value.agentId !== input.agentId || value.botId !== input.botId || value.readOnly !== (input.mode === 'read')
115
+ || value.protocol !== 'agentkit.personal-assistant.desktop.v1' || !Number.isFinite(remaining) || remaining <= 0 || remaining > 60_000
116
+ || !ws || ws.username || ws.password || ws.search || ws.hash || ws.pathname !== '/personal-assistant/desktop/ws'
117
+ || (ws.protocol !== 'wss:' && !(ws.protocol === 'ws:' && (local || allowHttp)))
118
+ || (value.leaseId != null && (typeof value.leaseId !== 'string' || value.leaseId.length > 256))) throw fail('PERSONAL_ASSISTANT_TICKET_INVALID', 'Studio 返回的桌面授权格式错误', 502);
119
+ return { ticket: value.ticket, expiresAt: value.expiresAt, agentId: input.agentId, botId: input.botId,
120
+ wsUrl: ws.href, protocol: value.protocol, leaseId: value.leaseId ?? null, readOnly: input.mode === 'read' };
121
+ }
122
+ const cloudSessions = cloudSessionTransport ?? createCloudSessionTransport({
123
+ issueTicket: (scope, signal) => request('personal-assistant.desktop.ticket', scope, signal),
124
+ WebSocketImpl, ca: trustedCAs(),
125
+ });
126
+ function publicBindingStatus(value) {
127
+ if (!plain(value) || typeof value.configured !== 'boolean') throw fail('PERSONAL_ASSISTANT_STUDIO_UNAVAILABLE', 'Studio 返回的绑定状态无效', 502);
128
+ const reason = typeof value.reason === 'string' ? value.reason.slice(0, 80) : '';
129
+ const candidates = Array.isArray(value.candidates) ? value.candidates.filter(row => plain(row) && typeof row.agentId === 'string' && AGENT_ID.test(row.agentId))
130
+ .slice(0, 20).map(row => ({ agentId: row.agentId, name: typeof row.name === 'string' ? row.name.slice(0, 80) : '',
131
+ status: typeof row.status === 'string' ? row.status.slice(0, 32) : '', createdAt: typeof row.createdAt === 'string' ? row.createdAt.slice(0, 40) : '' })) : [];
132
+ // The credential never reaches the page; only the public binding shape does.
133
+ return { configured: value.configured, agentId: value.configured && typeof value.agentId === 'string' ? value.agentId : undefined,
134
+ mode: 'studio', reason, candidates };
135
+ }
136
+ function publicBinding(value) {
137
+ if (!plain(value) || typeof value.configured !== 'boolean' || (value.configured && (typeof value.agentId !== 'string' || !AGENT_ID.test(value.agentId))))
138
+ throw fail('PERSONAL_ASSISTANT_STUDIO_UNAVAILABLE', 'Studio 返回的绑定结果无效', 502);
139
+ return { configured: value.configured, agentId: value.configured ? value.agentId : undefined, mode: 'studio' };
140
+ }
141
+ async function request(operation, payload = {}, signal) {
142
+ if (!plain(payload)) throw fail('PERSONAL_ASSISTANT_REQUEST_INVALID', '请求参数格式错误');
143
+ if (!OPERATIONS.has(operation)) throw fail('PERSONAL_ASSISTANT_OPERATION_UNAVAILABLE', '当前面板不支持此操作', 404);
144
+ const current = await binding(signal);
145
+ const bindingMethod = BINDING_METHODS[operation];
146
+ if (bindingMethod) {
147
+ if (payload.agentId !== undefined && operation !== 'personal-assistant.desktop.create') {
148
+ if (typeof payload.agentId !== 'string' || !AGENT_ID.test(payload.agentId)) throw fail('PERSONAL_ASSISTANT_AGENT_INVALID', '云端实例标识无效');
149
+ }
150
+ const studio = current.studio;
151
+ if (!studio) throw fail('PERSONAL_ASSISTANT_STANDALONE', '独立安装不支持自动发现;请在 Host 配置中连接云端实例', 409);
152
+ if (typeof studio[bindingMethod] !== 'function') throw fail('PERSONAL_ASSISTANT_STUDIO_UNAVAILABLE', '请更新 Studio 云连接服务', 503);
153
+ const created = operation === 'personal-assistant.desktop.create';
154
+ const scoped = created ? AbortSignal.timeout(360_000) : AbortSignal.timeout(15_000);
155
+ const deadline = signal ? AbortSignal.any([signal, scoped]) : scoped;
156
+ const input = !created && operation !== 'personal-assistant.desktop.status' && operation !== 'personal-assistant.desktop.disconnect' && payload.agentId
157
+ ? { agentId: String(payload.agentId) } : {};
158
+ let value;
159
+ try { value = await studio[bindingMethod](input, deadline); }
160
+ catch (error) {
161
+ const status = Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599 ? error.status : 503;
162
+ throw fail(error?.code || 'PERSONAL_ASSISTANT_BINDING_FAILED', String(error?.message || '个人助理连接失败').slice(0, 240), status);
163
+ }
164
+ if (bindingMethod === 'status') return publicBindingStatus(value);
165
+ return publicBinding(value);
166
+ }
167
+ if (operation === 'personal-assistant.configuration') return {
168
+ configured: current.configured, ...(current.configured ? { agentId: current.agentId } : {}),
169
+ ...(base ? { gatewayUrl: base.origin } : {}),
170
+ };
171
+ if (operation === 'personal-assistant.bots.list') {
172
+ if (!current.configured) return { bots: [], directoryAvailable: false };
173
+ const ticket = await request('personal-assistant.desktop.ticket', { agentId: current.agentId, botId: DIRECTORY_BOT_ID, mode: 'read' }, signal);
174
+ if (typeof WebSocketImpl !== 'function') throw fail('PERSONAL_ASSISTANT_DIRECTORY_UNAVAILABLE', '暂时无法读取助理列表,请重试', 503);
175
+ const querySignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000);
176
+ const directory = await new Promise((resolve, reject) => {
177
+ let socket, settled = false, connected = false;
178
+ const finish = (error, rows) => {
179
+ if (settled) return; settled = true;
180
+ querySignal.removeEventListener('abort', abort);
181
+ try { socket?.close(1000, 'directory complete'); } catch {}
182
+ if (error) reject(error); else resolve(rows);
183
+ };
184
+ const abort = () => finish(fail('PERSONAL_ASSISTANT_DIRECTORY_TIMEOUT', '读取助理列表超时,请重试', 504));
185
+ if (querySignal.aborted) return abort();
186
+ querySignal.addEventListener('abort', abort, { once: true });
187
+ try {
188
+ // 内网网关是自签链:`ws` 需要显式并入系统 CA,否则 onerror 表现为目录不可用。
189
+ socket = new WebSocketImpl(ticket.wsUrl, ['agentkit.personal-assistant.desktop.v1', `agentkit.desktop.ticket.${ticket.ticket}`], { ca: trustedCAs() });
190
+ }
191
+ catch { finish(fail('PERSONAL_ASSISTANT_DIRECTORY_UNAVAILABLE', '暂时无法读取助理列表,请重试', 502)); return; }
192
+ socket.once?.('unexpected-response', (_request, response) => {
193
+ const status = Number(response.statusCode) || 502;
194
+ response.resume?.();
195
+ finish(fail(status >= 500 ? 'PERSONAL_ASSISTANT_DIRECTORY_UNAVAILABLE' : 'PERSONAL_ASSISTANT_DIRECTORY_REFUSED', '云端拒绝读取助理列表,请检查连接授权', status >= 400 ? status : 502));
196
+ });
197
+ socket.onopen = () => { connected = true; socket.send(JSON.stringify({ type: 'bots.list' })); };
198
+ // 未完成握手时的不透明错误可能是鉴权拒绝,不能当作普通目录故障回退。
199
+ socket.onerror = () => finish(fail(connected ? 'PERSONAL_ASSISTANT_DIRECTORY_UNAVAILABLE' : 'PERSONAL_ASSISTANT_DIRECTORY_REFUSED', '暂时无法读取助理列表,请检查连接授权后重试', 502));
200
+ socket.onclose = event => {
201
+ const closeCode = safeCloseCode(event?.code);
202
+ const error = fail(!connected || closeCode === 1008 ? 'PERSONAL_ASSISTANT_DIRECTORY_REFUSED' : 'PERSONAL_ASSISTANT_DIRECTORY_UNAVAILABLE', `助理目录连接已结束${closeCode ? `(关闭码 ${closeCode})` : ''},请重试`, closeCode === 1008 ? 403 : 502);
203
+ // reason 由远端提供,可能含票据或内部信息;仅保留数值关闭码。
204
+ if (closeCode) error.closeCode = closeCode;
205
+ finish(error);
206
+ };
207
+ socket.onmessage = event => {
208
+ try {
209
+ if (typeof event.data !== 'string' || event.data.length > 64 * 1024) throw new Error('invalid directory');
210
+ const value = JSON.parse(event.data);
211
+ if (value.type === 'bots') finish(null, { bots: publicBotDirectory(value.bots), ...(Array.isArray(value.features) && value.features.includes('desktop-handoff/v1') ? { features: ['desktop-handoff/v1'] } : {}) });
212
+ else if (value.type === 'error') {
213
+ if (value.code === 'DESKTOP_MESSAGE_UNSUPPORTED') finish(fail('PERSONAL_ASSISTANT_DIRECTORY_UNSUPPORTED', '当前云端版本尚不支持助理列表', 409));
214
+ else {
215
+ const status = Number(value.status) || 400;
216
+ finish(fail(status >= 500 ? 'PERSONAL_ASSISTANT_DIRECTORY_UNAVAILABLE' : 'PERSONAL_ASSISTANT_DIRECTORY_REFUSED', '云端拒绝读取助理列表,请检查连接授权', status));
217
+ }
218
+ }
219
+ } catch { finish(fail('PERSONAL_ASSISTANT_DIRECTORY_INVALID', '助理目录格式不兼容,请更新云端助理后重试', 502)); }
220
+ };
221
+ });
222
+ return { ...directory, directoryAvailable: true };
223
+ }
224
+ if (!current.configured) throw fail('PERSONAL_ASSISTANT_GATEWAY_UNCONFIGURED', '个人助理尚未绑定云端实例', 409);
225
+ if (payload.agentId && payload.agentId !== current.agentId) throw fail('PERSONAL_ASSISTANT_AGENT_MISMATCH', '云端实例与 Host 绑定不一致', 403);
226
+ const botId = String(payload.botId || '').trim();
227
+ if (!botId || botId.length > 64 || /[\u0000-\u001f]/u.test(botId)) throw fail('PERSONAL_ASSISTANT_BOT_REQUIRED', '请选择已创建的助理');
228
+ if (SESSION_OPERATIONS.includes(operation)) {
229
+ if (botId === DIRECTORY_BOT_ID) throw fail('PERSONAL_ASSISTANT_BOT_REQUIRED', '请选择真实云端助理', 403);
230
+ if (!['personal-assistant.sessions.list', 'personal-assistant.session.create'].includes(operation)) requireSessionId(payload.sessionId);
231
+ if (payload.cursor !== undefined && (!Number.isSafeInteger(payload.cursor) || payload.cursor < 0)) throw fail('PERSONAL_ASSISTANT_REQUEST_INVALID', '增量游标无效');
232
+ return cloudSessions.request({ agentId: current.agentId, botId }, operation, payload, signal);
233
+ }
234
+ if (payload.mode !== undefined && !['read', 'control'].includes(payload.mode)) throw fail('PERSONAL_ASSISTANT_REQUEST_INVALID', '桌面访问模式无效');
235
+ if (botId === DIRECTORY_BOT_ID && payload.mode !== 'read') throw fail('PERSONAL_ASSISTANT_REQUEST_INVALID', '目录授权只能用于只读查询', 403);
236
+ if (current.studio) {
237
+ const input = { agentId: current.agentId, botId, mode: payload.mode === 'read' ? 'read' : 'control', ...(payload.leaseId ? { leaseId: String(payload.leaseId) } : {}) };
238
+ return publicStudioTicket(await callStudio(current.studio, 'issueTicket', input, signal), input);
239
+ }
240
+ const response = await fetchImpl(new URL('IssuePersonalAssistantDesktopTicket', base), {
241
+ method: 'POST', headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
242
+ body: JSON.stringify({ AgentId: agentId, BotId: botId, Mode: payload.mode === 'read' ? 'read' : 'control', ...(payload.leaseId ? { LeaseId: String(payload.leaseId) } : {}) }),
243
+ signal: signal || AbortSignal.timeout(10_000), redirect: 'error',
244
+ });
245
+ const result = await response.json().catch(() => ({}));
246
+ if (!response.ok || result.Code !== 0) throw fail('PERSONAL_ASSISTANT_TICKET_REFUSED', `云网关拒绝连接(${response.status})${result.Message ? `:${String(result.Message).slice(0, 160)}` : ''}`, response.status >= 400 ? response.status : 400);
247
+ const data = result.Data;
248
+ if (!plain(data) || !/^[A-Za-z0-9_-]{32,512}$/u.test(String(data.Ticket || '')) || data.AgentId !== agentId || data.BotId !== botId || data.WsPath !== 'personal-assistant/desktop/ws') throw fail('PERSONAL_ASSISTANT_TICKET_INVALID', '云网关返回的桌面授权格式错误', 502);
249
+ const ws = new URL(data.WsPath, base);
250
+ ws.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
251
+ return { ticket: data.Ticket, expiresAt: data.ExpiresAt, agentId, botId, wsUrl: ws.href, protocol: 'agentkit.personal-assistant.desktop.v1', leaseId: data.LeaseId || null, readOnly: payload.mode === 'read' };
252
+ }
253
+ return Object.freeze({ request, dispose: () => cloudSessions.dispose() });
254
+ }
255
+
256
+ export function registerPersonalAssistantHostGateway(ctx, config = {}, { sessionChannel, channelOptions } = {}) {
257
+ if (!ctx?.connection?.fetch?.register) return undefined;
258
+ const gateway = createPersonalAssistantHostGateway(config, { getStudioDesktop: () => ctx.get?.(STUDIO_DESKTOP_SERVICE) });
259
+ // Connection 的 /api 物理载体先执行 Host/Origin、浏览器鉴权及 owner Peer 校验。
260
+ // 使用公开 exact Fetch 注册表,避免自建服务、抢占 Gateway interceptor 或修改 remotes。
261
+ const unregister = ctx.connection.fetch.register({ path: ENDPOINT, methods: ['POST'], requestBody: 'buffered', async fetch(request) {
262
+ let rpcId = 'invalid-request';
263
+ const respond = result => Response.json({ type: 'server-response', rpcId, result }, { headers: { 'cache-control': 'no-store' } });
264
+ try {
265
+ if (request.method !== 'POST') throw fail('PERSONAL_ASSISTANT_METHOD_INVALID', '仅支持 POST 请求', 405);
266
+ if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') throw fail('PERSONAL_ASSISTANT_CONTENT_TYPE_INVALID', '请求必须使用 JSON', 415);
267
+ if (Number(request.headers.get('content-length')) > 768 * 1024) throw fail('PERSONAL_ASSISTANT_REQUEST_TOO_LARGE', '请求过大', 413);
268
+ const text = await request.text(); request.signal.throwIfAborted();
269
+ if (Buffer.byteLength(text) > 768 * 1024) throw fail('PERSONAL_ASSISTANT_REQUEST_TOO_LARGE', '请求过大', 413);
270
+ let body; try { body = JSON.parse(text); } catch { if (Buffer.byteLength(text) > 16_384) throw fail('PERSONAL_ASSISTANT_REQUEST_TOO_LARGE', '请求过大', 413); throw fail('PERSONAL_ASSISTANT_REQUEST_INVALID', '请求 JSON 格式错误'); }
271
+ if (body?.payload?.operation !== 'personal-assistant.file.upload' && Buffer.byteLength(text) > 16_384) throw fail('PERSONAL_ASSISTANT_REQUEST_TOO_LARGE', '请求过大', 413);
272
+ if (!plain(body) || body.type !== 'client-request' || typeof body.rpcId !== 'string' || !/^[A-Za-z0-9_-]{1,80}$/u.test(body.rpcId) || body.method !== 'personal-assistant' || !plain(body.payload)) throw fail('PERSONAL_ASSISTANT_REQUEST_INVALID', '请求格式错误');
273
+ rpcId = body.rpcId;
274
+ if (!OPERATIONS.has(body.payload.operation)) throw fail('PERSONAL_ASSISTANT_OPERATION_UNAVAILABLE', '当前面板不支持此操作', 404);
275
+ return respond({ ok: true, value: await gateway.request(body.payload.operation, body.payload.payload ?? {}, request.signal) });
276
+ } catch (error) {
277
+ return respond({ ok: false, error: { code: error.code || 'PERSONAL_ASSISTANT_GATEWAY_FAILED', message: error.message || '云电脑连接失败', details: { status: error.status || 500, ...(safeCloseCode(error.closeCode) ? { closeCode: error.closeCode } : {}) } } });
278
+ }
279
+ } });
280
+ return () => { unregister?.(); gateway.dispose(); };
281
+ }