dsh-agentone 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,895 @@
1
+ // dsh-agentone 主入口:挂 /agentone/* 路由(dsh webServer 原生 Node handler)。
2
+ //
3
+ // M1(飞书登录)页面与 API:
4
+ // GET /agentone/ 状态页(登录/登出/模型同步状态)
5
+ // GET /agentone/api/status {platform_url, logged_in, user_email, model}
6
+ // POST /agentone/api/login {callback_url} → {login_url}(浏览器打开)
7
+ // GET /agentone/callback?code&state 平台 landing 跳回:换令牌后自动配置模型
8
+ // POST /agentone/api/logout 清除本地凭据并移除平台模型配置
9
+ // POST /agentone/api/model/sync 重新拉取平台模型并写入 settings.yaml
10
+ //
11
+ // M2(平台模型):登录成功与每次启动时自动执行 applyPlatformModel——拉平台
12
+ // /api/plugin/v1/models,写入 $DSH_HOME/settings.yaml(llm-pi-ai.providers.platform
13
+ // + agent-default-model)与 .credentials.yaml(refs.AGENTONE_API_KEY,0600)。
14
+ // dsh 侧模型选择器随即出现「平台模型」,对话走平台代理,真实密钥不出平台。
15
+ import z from '@deepseek-ai/schemastery';
16
+ import { createRequire } from 'node:module';
17
+ import { readFile } from 'node:fs/promises';
18
+ import { join } from 'node:path';
19
+ import {
20
+ applyPlatformPlan,
21
+ buildLoginUrl,
22
+ exchangeCode,
23
+ fetchPlatformCapabilities,
24
+ fetchPlatformPlan,
25
+ fetchPlatformProfile,
26
+ newLoginState,
27
+ refreshPlatformToken,
28
+ revokePlatformToken,
29
+ } from './auth.js';
30
+ import { applyPlatformModel, removePlatformModel } from './model.js';
31
+ import { installPlatformSkill, listPlatformSkills, removePlatformSkill } from './skill.js';
32
+ import { platformFetch } from './http.js';
33
+ import { donePageHtml, statusPageHtml } from './page.js';
34
+ import { dshPluginCli, probeCliTool, pnpmCli } from './proc.js';
35
+
36
+ import { clearCredentials, loadCredentials, resolveDshHome, saveCredentials, updateCredentials } from './store.js';
37
+
38
+ const require = createRequire(import.meta.url);
39
+
40
+ /** 插件自身版本(关于页展示)。 */
41
+ const PLUGIN_VERSION = require('../package.json').version;
42
+ /** 插件自身的 npm 包名(插件管理里禁止卸载/升级自己)。 */
43
+ const PLUGIN_PACKAGE_NAME = require('../package.json').name;
44
+
45
+ export const name = 'agentone';
46
+ export const inject = ['webServer'];
47
+
48
+ export const Config = z.object({
49
+ platformUrl: z
50
+ .string()
51
+ .default('https://ccpg.one.agentone.work')
52
+ .description('AgentOne 平台地址(本地开发在 profile cordis.patch.yml 覆盖为 http://127.0.0.1:8558)'),
53
+ storeDir: z.string().default('').description('凭据存储目录,空则用 $DSH_HOME/agentone'),
54
+ });
55
+
56
+ /** 本次进程内的登录 CSRF state(/callback 比对后即失效)。 */
57
+ const pendingStates = new Set();
58
+
59
+ function sendJson(response, status, data) {
60
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
61
+ response.end(JSON.stringify(data));
62
+ }
63
+
64
+ async function readJsonBody(request, limitBytes = 8192) {
65
+ // 只认 application/json:恶意页面可用 <form enctype="text/plain"> 跨站提交
66
+ // 完全可控的 JSON body(fetch 跨源 JSON 会被预检拦住,表单不会),
67
+ // 借此驱动登出/装包等写操作。浏览器同源 fetch 一定带本类型。
68
+ const contentType = String(request.headers?.['content-type'] || '').toLowerCase();
69
+ if (!contentType.includes('application/json')) {
70
+ throw new Error('请求类型不正确');
71
+ }
72
+ const chunks = [];
73
+ let size = 0;
74
+ for await (const chunk of request) {
75
+ size += chunk.length;
76
+ if (size > limitBytes) throw new Error('请求体过大');
77
+ chunks.push(chunk);
78
+ }
79
+ if (chunks.length === 0) return {};
80
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
81
+ }
82
+
83
+ /** 应用平台模型配置并把结果落盘(成功清 model_error,失败记录原因与错误码)。 */
84
+ async function syncPlatformModel(config, log) {
85
+ try {
86
+ const result = await applyPlatformModel(config);
87
+ await updateCredentials(config, {
88
+ models_count: result.models.length,
89
+ default_model: result.defaultModel,
90
+ model_synced_at: new Date().toISOString(),
91
+ model_error: null,
92
+ model_error_code: null,
93
+ });
94
+ log.info(`[dsh-agentone] platform model applied: ${result.defaultModel}(共 ${result.models.length} 个可用模型)`);
95
+ return result;
96
+ } catch (error) {
97
+ const message = error?.message || String(error);
98
+ await updateCredentials(config, {
99
+ model_error: message,
100
+ model_error_code: error?.code || null,
101
+ }).catch(() => {});
102
+ log.warn(`[dsh-agentone] platform model sync failed: ${message}`);
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ /** 进行中的令牌轮换:临期窗口内多个请求共享同一次 refresh(轮换是
108
+ * 一次性的,并发各刷一次会 401 把用户登出)。 */
109
+ let tokenRefreshInFlight = null;
110
+
111
+ /**
112
+ * 令牌保鲜:access 临期(5 分钟内)/过期且有 refresh_token 时轮换,并把新
113
+ * access 重新写入 dsh 凭据库(.credentials.yaml 的 AGENTONE_API_KEY 引用)。
114
+ * - 轮换失败且 401(refresh 已吊销)→ 清本地凭据,状态回到未登录;
115
+ * - 网络错误 → 保留凭据下次再试(返回旧凭据)。
116
+ */
117
+ async function ensureFreshToken(config, log) {
118
+ const credentials = await loadCredentials(config);
119
+ if (!credentials || !credentials.refresh_token) return credentials;
120
+ const expiresAt = credentials.expires_at ? Date.parse(credentials.expires_at) : 0;
121
+ if (!expiresAt || expiresAt - Date.now() > 5 * 60 * 1000) return credentials;
122
+ if (!tokenRefreshInFlight) {
123
+ tokenRefreshInFlight = (async () => {
124
+ try {
125
+ const refreshed = await refreshPlatformToken(config.platformUrl, credentials.refresh_token);
126
+ const expiresIn = Number(refreshed.expires_in) || 0;
127
+ const updated = {
128
+ ...credentials,
129
+ access_token: refreshed.access_token,
130
+ refresh_token: refreshed.refresh_token,
131
+ saved_at: new Date().toISOString(),
132
+ expires_at: expiresIn ? new Date(Date.now() + expiresIn * 1000).toISOString() : null,
133
+ };
134
+ await saveCredentials(config, updated);
135
+ // 凭据库引用指向旧 access,必须随轮换重写
136
+ await applyPlatformModel(config).catch(() => {});
137
+ log.info('[dsh-agentone] platform token refreshed');
138
+ return updated;
139
+ } catch (error) {
140
+ if (error?.status === 401) {
141
+ log.warn('[dsh-agentone] refresh rejected, clearing local credentials');
142
+ await clearCredentials(config);
143
+ return null;
144
+ }
145
+ log.warn(`[dsh-agentone] token refresh failed: ${error?.message || error}`);
146
+ return credentials;
147
+ } finally {
148
+ tokenRefreshInFlight = null;
149
+ }
150
+ })();
151
+ }
152
+ return tokenRefreshInFlight;
153
+ }
154
+
155
+ /** web profile 固定名(dsh 控制台只跑 web profile)。 */
156
+ const PLUGIN_PROFILE = 'web';
157
+
158
+ /**
159
+ * npm 包名白名单(scoped 与非 scoped)。用户输入会进入 CLI 参数,必须先
160
+ * 收敛到 npm 官方命名规则,杜绝以 `-` 开头的值被 dsh/pnpm 当成 flag。
161
+ */
162
+ const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
163
+ /** 版本白名单:语义化版本、^ ~ 前缀与 latest 标签。 */
164
+ const VERSION_RE = /^(?:\^|~)?\d+(?:\.\d+){0,3}(?:[-+][a-z0-9._-]+)?$|^latest$/;
165
+
166
+ function pluginProfileDir(config) {
167
+ return join(resolveDshHome(config), 'profiles', PLUGIN_PROFILE);
168
+ }
169
+
170
+ /** 校验用户输入的包名/版本;非法值直接拒绝(中文报错)。 */
171
+ function assertValidPackage(name, version) {
172
+ if (!NPM_NAME_RE.test(name)) {
173
+ throw new Error('插件名不合法:仅允许小写字母、数字和 . _ -(scoped 名如 @scope/pkg)');
174
+ }
175
+ if (version !== undefined && version !== '' && !VERSION_RE.test(version)) {
176
+ throw new Error('版本号不合法:仅允许语义化版本(如 1.2.3、^1.2.0)或 latest');
177
+ }
178
+ }
179
+
180
+ async function readJsonFile(path) {
181
+ try {
182
+ return JSON.parse(await readFile(path, 'utf8'));
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ /** 从 node_modules 里读一个包的已装版本(读不到返回 null)。 */
189
+ async function installedVersion(profile, name) {
190
+ const manifest = await readJsonFile(join(profile, 'node_modules', name, 'package.json'));
191
+ return typeof manifest?.version === 'string' ? manifest.version : null;
192
+ }
193
+
194
+ /** 包是否带 dsh.bundle 声明(决定它在层列表里是否被激活)。 */
195
+ async function isBundlePackage(profile, name) {
196
+ const manifest = await readJsonFile(join(profile, 'node_modules', name, 'package.json'));
197
+ return Boolean(manifest?.dsh?.bundle);
198
+ }
199
+
200
+ /** link:/file: 等本地路径依赖展示短标签(没有版本号可读)。 */
201
+ function specLabel(spec) {
202
+ if (typeof spec !== 'string') return '';
203
+ if (spec.startsWith('link:') || spec.startsWith('file:')) return '本地链接';
204
+ return '';
205
+ }
206
+
207
+ /** dsh 官方 in-box bundle:profile 模板自带,不是用户可管理的插件。 */
208
+ const INBOX_BUNDLES = new Set([
209
+ '@deepseek-ai/dsh-base',
210
+ '@deepseek-ai/dsh-web-app',
211
+ '@deepseek-ai/dsh-headless',
212
+ ]);
213
+
214
+ /**
215
+ * 镜像预装插件(docker/dsh/Dockerfile.default 构建期通过 dsh plugin add
216
+ * 装入 /opt/prebuilt/dsh-home,init.sh 首启物化到持久卷)。用户可以卸载,
217
+ * 但它们承载控制台核心体验(侧栏宿主/Workflow One/插件市场/IM 桥),所以
218
+ * 每次 dsh 启动时检测:缺失则静默重装(常驻保证),已装则不碰(尊重用户
219
+ * 的版本选择)。
220
+ */
221
+ const BUILTIN_PLUGINS = ['dsh-better-sidebar', 'dsh-harness-one', 'dshmarket', 'dsh-im'];
222
+
223
+ /**
224
+ * 解析 pnpm outdated 输出:两行一组,包名行 + "current => latest" 行。
225
+ * 输出格式随 pnpm 版本变化:解析出的条目必须满足「上一行是已知的插件
226
+ * 包名」才采纳,格式不认识时返回空表(所有 latest 为 null,「升级」按钮
227
+ * 整体不出现),而不是错配出假版本号。
228
+ */
229
+ function parseOutdatedList(stdout, knownNames = []) {
230
+ const result = {};
231
+ const known = new Set(knownNames);
232
+ const rows = String(stdout).split('\n').map((line) => line.trim()).filter(Boolean);
233
+ rows.forEach((line, i) => {
234
+ const match = i > 0 ? /^(\S+)\s+=>\s+(\S+)$/.exec(line) : null;
235
+ if (match && (!known.size || known.has(rows[i - 1]))) {
236
+ result[rows[i - 1]] = { current: match[1], latest: match[2] };
237
+ }
238
+ });
239
+ return result;
240
+ }
241
+
242
+ /**
243
+ * 内置插件的用途说明(以各自官方 README 为准):用户视角讲清楚装了能
244
+ * 干什么、在哪里用到。
245
+ */
246
+ const BUILTIN_PLUGIN_DESCRIPTIONS = {
247
+ 'dsh-better-sidebar':
248
+ '侧边工作台:文件管理/编辑预览、内嵌浏览器、真实终端、Git 文件变动,聊天里的文件点开即看',
249
+ 'dsh-harness-one':
250
+ 'Workflow One:可视化编排 AI 工作流,像画流程图一样串联任务,支持推送结果到飞书',
251
+ dshmarket:
252
+ '插件市场:在 dsh 设置里逛社区插件,像应用商店一样点一下就装好',
253
+ 'dsh-im':
254
+ 'IM 桥核心:让 dsh 接入飞书等聊天工具——群内 @机器人 干活、收通知与审批卡片',
255
+ };
256
+
257
+ /** 汇总 profile 插件状态:dependencies(去掉 in-box)+ 已装版本 + 激活态。 */
258
+ async function listProfilePlugins(config) {
259
+ const dir = pluginProfileDir(config);
260
+ const manifest = await readJsonFile(join(dir, 'package.json'));
261
+ const dependencies = manifest?.dependencies ?? {};
262
+ const bundles = Array.isArray(manifest?.dsh?.profile?.bundles)
263
+ ? manifest.dsh.profile.bundles
264
+ : [];
265
+ const plugins = [];
266
+ const seen = new Set();
267
+ for (const [name, spec] of Object.entries(dependencies)) {
268
+ if (INBOX_BUNDLES.has(name)) continue;
269
+ seen.add(name);
270
+ const version = await installedVersion(dir, name);
271
+ const bundle = version !== null && (await isBundlePackage(dir, name));
272
+ plugins.push({
273
+ name,
274
+ spec,
275
+ version,
276
+ source: specLabel(spec) || 'npm',
277
+ bundled: bundle,
278
+ // 激活 = 已装 + 带 bundle 声明 + 出现在 dsh.profile.bundles
279
+ active: bundle && bundles.includes(name),
280
+ builtin: BUILTIN_PLUGINS.includes(name),
281
+ // 自身随平台镜像升级:前端据此隐藏卸载/升级按钮(与后端拒绝逻辑同源)
282
+ self_managed: name === PLUGIN_PACKAGE_NAME,
283
+ description: BUILTIN_PLUGIN_DESCRIPTIONS[name] || null,
284
+ });
285
+ }
286
+ // 内置插件被卸载后不在 dependencies 里,但仍要出现在列表中
287
+ // (状态「未安装」+「安装」按钮),否则用户无从感知与恢复。
288
+ for (const name of BUILTIN_PLUGINS) {
289
+ if (seen.has(name)) continue;
290
+ plugins.push({
291
+ name,
292
+ spec: null,
293
+ version: null,
294
+ source: 'npm',
295
+ bundled: false,
296
+ active: false,
297
+ builtin: true,
298
+ self_managed: false,
299
+ description: BUILTIN_PLUGIN_DESCRIPTIONS[name] || null,
300
+ });
301
+ }
302
+ return {
303
+ profile: PLUGIN_PROFILE,
304
+ bundles: bundles.filter((name) => !INBOX_BUNDLES.has(name)),
305
+ plugins,
306
+ };
307
+ }
308
+
309
+ /** dsh plugin add 的构建脚本放行参数(与镜像 Dockerfile 预装命令同源)。
310
+ * pnpm 11 默认拦截依赖构建脚本,node-pty(sidebar/harness-one 的终端依赖)
311
+ * 被拦会导致 add 非零退出、bundles reconcile 不执行——包装上了却永远
312
+ * 「未激活」。用 = 连接形式,避免空格分隔被 pnpm 拆成两个参数。
313
+ */
314
+ const BUILTIN_PLUGIN_ALLOW_BUILD = {
315
+ 'dsh-better-sidebar': ['--allow-build=node-pty'],
316
+ 'dsh-harness-one': ['--allow-build=node-pty'],
317
+ dshmarket: ['--allow-build=node-pty'],
318
+ 'dsh-im': [],
319
+ };
320
+
321
+ /** 安装:白名单校验后转发官方 CLI(参数数组,无 shell)。 */
322
+ async function installProfilePlugin(config, name, version) {
323
+ assertValidPackage(name, version);
324
+ const target = version ? `${name}@${version}` : name;
325
+ await dshPluginCli(['add', target, ...(BUILTIN_PLUGIN_ALLOW_BUILD[name] || [])]);
326
+ return listProfilePlugins(config);
327
+ }
328
+
329
+ /** 卸载:自身与内置插件拒绝(无卸载按钮 + 后端兜底);白名单校验后转发官方 CLI。 */
330
+ async function removeProfilePlugin(config, name) {
331
+ assertValidPackage(name);
332
+ if (INBOX_BUNDLES.has(name)) {
333
+ throw new Error('dsh 内置组件不可卸载');
334
+ }
335
+ if (BUILTIN_PLUGINS.includes(name)) {
336
+ throw new Error('内置插件承载 dsh 核心体验,不可卸载');
337
+ }
338
+ if (name === PLUGIN_PACKAGE_NAME) {
339
+ throw new Error('AgentOne 插件是登录与模型配置的载体,不能卸载自己');
340
+ }
341
+ await dshPluginCli(['remove', name]);
342
+ return listProfilePlugins(config);
343
+ }
344
+
345
+ /** 升级到 manifest 区间内最新版:dsh plugin update;in-box 随 dsh 本体升级。 */
346
+ async function upgradeProfilePlugin(config, name) {
347
+ assertValidPackage(name);
348
+ if (INBOX_BUNDLES.has(name)) {
349
+ throw new Error('dsh 内置组件随 dsh 一同升级,请等待平台更新 dsh');
350
+ }
351
+ if (name === PLUGIN_PACKAGE_NAME) {
352
+ throw new Error('AgentOne 插件随平台镜像一同升级,无需手动操作');
353
+ }
354
+ await dshPluginCli(['update', name]);
355
+ return listProfilePlugins(config);
356
+ }
357
+
358
+ /**
359
+ * 内置插件常驻检测:镜像预装插件缺失时逐个静默重装。单个失败不阻断
360
+ * (网络抖动等),下一个启动周期再试;全部已装时零开销。
361
+ */
362
+ async function ensureBuiltinPlugins(config, log) {
363
+ const state = await listProfilePlugins(config);
364
+ const installed = new Set(state.plugins.map((plugin) => plugin.name));
365
+ const missing = BUILTIN_PLUGINS.filter((name) => !installed.has(name));
366
+ if (!missing.length) return [];
367
+ const reinstalled = [];
368
+ for (const name of missing) {
369
+ try {
370
+ await installProfilePlugin(config, name);
371
+ reinstalled.push(name);
372
+ log.info(`[dsh-agentone] builtin plugin reinstalled: ${name}`);
373
+ } catch (error) {
374
+ log.warn(`[dsh-agentone] builtin plugin reinstall failed (${name}): ${error?.message || error}`);
375
+ }
376
+ }
377
+ return reinstalled;
378
+ }
379
+
380
+ /**
381
+ * 套餐技能自动同步:平台上 source=plan 且未安装的技能静默装齐(与平台托管
382
+ * 实例的套餐技能预装语义对齐);单个失败不阻断其余,结果记 credentials。
383
+ */
384
+ async function syncPlanSkills(config, log, credentials) {
385
+ const token = credentials?.access_token;
386
+ if (!token) return null;
387
+ let listing;
388
+ try {
389
+ listing = await listPlatformSkills(config.platformUrl, token);
390
+ } catch (error) {
391
+ log.warn(`[dsh-agentone] skills list failed: ${error?.message || error}`);
392
+ await updateCredentials(config, { skills_error: String(error?.message || error) }).catch(
393
+ () => {},
394
+ );
395
+ return null;
396
+ }
397
+ const pending = (listing.skills || []).filter((skill) => skill.source === 'plan' && !skill.installed);
398
+ let installed = 0;
399
+ const failures = [];
400
+ for (const skill of pending) {
401
+ try {
402
+ await installPlatformSkill(config.platformUrl, token, skill.canonical_slug, {
403
+ config,
404
+ source: 'plan',
405
+ });
406
+ installed += 1;
407
+ log.info(`[dsh-agentone] plan skill installed: ${skill.canonical_slug}`);
408
+ } catch (error) {
409
+ failures.push(`${skill.canonical_slug}: ${error?.message || error}`);
410
+ }
411
+ }
412
+ await updateCredentials(config, {
413
+ skills_synced_at: new Date().toISOString(),
414
+ skills_error: failures.length ? failures.join(';') : null,
415
+ }).catch(() => {});
416
+ if (failures.length) log.warn(`[dsh-agentone] plan skill failures: ${failures.join(';')}`);
417
+ return { installed, failed: failures.length };
418
+ }
419
+
420
+
421
+ /**
422
+ * status 内缓存:skills/catalog 内容几乎不变,却被 5s 轮询每次全量重拉。
423
+ * 缓存 45s;模型同步结果本就落盘在 credentials,不受缓存影响。
424
+ * capabilities(含 plan_state)不缓存:前端靠轮询它感知「套餐审批通过」,
425
+ * 缓存会把自动同步模型的延迟从 5s 拖到 50s。缓存命中时只省 skills/catalog
426
+ * 两个请求,capabilities 仍每次实时拉。
427
+ */
428
+ const STATUS_CACHE_TTL_MS = 45_000;
429
+ let statusCache = { at: 0, key: null, skills: null, displayNames: null };
430
+
431
+ async function cachedPlatformStatus(config, credentials) {
432
+ const capabilities = await fetchPlatformCapabilities(config.platformUrl, credentials.access_token);
433
+ const key = `${config.platformUrl}|${credentials.access_token}`;
434
+ if (statusCache.key === key && Date.now() - statusCache.at < STATUS_CACHE_TTL_MS) {
435
+ return { ...statusCache, capabilities };
436
+ }
437
+ const skills = capabilities?.channels?.skillhub?.enabled
438
+ ? await listPlatformSkills(config.platformUrl, credentials.access_token).catch(() => null)
439
+ : null;
440
+ // canonical slug → 市场名称(前端用友好名展示,slug 仅作操作标识)
441
+ let displayNames = {};
442
+ if (skills?.skills?.length) {
443
+ const catalog = await platformFetch(
444
+ `${config.platformUrl.replace(/\/+$/, '')}/api/plugin/v1/skills/catalog`,
445
+ { headers: { Authorization: `Bearer ${credentials.access_token}` } },
446
+ )
447
+ .then((r) => (r.ok ? r.json() : null))
448
+ .catch(() => null);
449
+ if (catalog?.items) {
450
+ for (const item of catalog.items) {
451
+ if (item.slug && item.display_name) displayNames[item.slug] = item.display_name;
452
+ }
453
+ }
454
+ }
455
+ statusCache = { at: Date.now(), key, skills, displayNames };
456
+ return { ...statusCache, capabilities };
457
+ }
458
+
459
+ export function apply(ctx, config) {
460
+ const log = ctx.logger || console;
461
+ ctx.effect(() => {
462
+ const register = (path, handler) => ctx.webServer.register({ kind: 'exact', path, handler });
463
+
464
+ register('/agentone/', (request, response) => {
465
+ if (request.method !== 'GET') {
466
+ response.writeHead(405, { allow: 'GET' });
467
+ response.end();
468
+ return;
469
+ }
470
+ response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
471
+ response.end(statusPageHtml());
472
+ });
473
+
474
+ register('/agentone/done', (request, response) => {
475
+ if (request.method !== 'GET') {
476
+ response.writeHead(405, { allow: 'GET' });
477
+ response.end();
478
+ return;
479
+ }
480
+ const ok = new URL(request.url, 'http://localhost').searchParams.get('ok') !== '0';
481
+ response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
482
+ response.end(donePageHtml(ok));
483
+ });
484
+
485
+ register('/agentone/api/status', async (request, response) => {
486
+ if (request.method !== 'GET') {
487
+ response.writeHead(405, { allow: 'GET' });
488
+ response.end();
489
+ return;
490
+ }
491
+ try {
492
+ // 顺手做令牌保鲜:临期自动轮换,refresh 失效则清凭据回到未登录
493
+ const credentials = await ensureFreshToken(config, log);
494
+ let userEmail = null;
495
+ let capabilities = null;
496
+ let skills = null;
497
+ let displayNames = {};
498
+ if (credentials) {
499
+ const profile = await fetchPlatformProfile(config.platformUrl, credentials.access_token);
500
+ userEmail = profile?.email ?? null;
501
+ if (userEmail) {
502
+ ({ capabilities, skills, displayNames } = await cachedPlatformStatus(config, credentials));
503
+ }
504
+ }
505
+ sendJson(response, 200, {
506
+ platform_url: config.platformUrl,
507
+ logged_in: Boolean(userEmail),
508
+ user_email: userEmail,
509
+ token_expires_at: credentials?.expires_at ?? null,
510
+ plugin_version: PLUGIN_VERSION,
511
+ capabilities,
512
+ skills,
513
+ skill_display_names: displayNames,
514
+ skills_synced_at: credentials?.skills_synced_at ?? null,
515
+ skills_error: credentials?.skills_error ?? null,
516
+ model: credentials
517
+ ? {
518
+ default_model: credentials.default_model ?? null,
519
+ models_count: credentials.models_count ?? 0,
520
+ synced_at: credentials.model_synced_at ?? null,
521
+ error: credentials.model_error ?? null,
522
+ error_code: credentials.model_error_code ?? null,
523
+ }
524
+ : null,
525
+ });
526
+ } catch (error) {
527
+ log.warn(`[dsh-agentone] status failed: ${error?.message || error}`);
528
+ sendJson(response, 500, { error: '读取登录状态失败' });
529
+ }
530
+ });
531
+
532
+ register('/agentone/api/login', async (request, response) => {
533
+ if (request.method !== 'POST') {
534
+ response.writeHead(405, { allow: 'POST' });
535
+ response.end();
536
+ return;
537
+ }
538
+ try {
539
+ const body = await readJsonBody(request);
540
+ const callbackUrl = String(body.callback_url || '');
541
+ if (!/^https?:\/\/|^\/portal\//.test(callbackUrl)) {
542
+ sendJson(response, 400, { error: '回调地址不合法' });
543
+ return;
544
+ }
545
+ const state = newLoginState();
546
+ pendingStates.add(state);
547
+ sendJson(response, 200, { login_url: buildLoginUrl(config.platformUrl, callbackUrl, state) });
548
+ } catch (error) {
549
+ log.warn(`[dsh-agentone] login failed: ${error?.message || error}`);
550
+ sendJson(response, 500, { error: '发起登录失败' });
551
+ }
552
+ });
553
+
554
+ register('/agentone/callback', async (request, response) => {
555
+ if (request.method !== 'GET') {
556
+ response.writeHead(405, { allow: 'GET' });
557
+ response.end();
558
+ return;
559
+ }
560
+ const params = new URL(request.url, 'http://localhost').searchParams;
561
+ const code = params.get('code') || '';
562
+ // 相对跳转完成页:独立版落在 /agentone/done,托管版落在控制台代理路径下
563
+ const redirect = (ok) => {
564
+ response.writeHead(302, { location: `done?ok=${ok ? 1 : 0}` });
565
+ response.end();
566
+ };
567
+ // 平台 grant 的 redirect_url 只携带 code;state 存在平台侧、由 exchange
568
+ // 响应回传(见 app/api/plugin_auth.py)。因此先换令牌,再用响应中的
569
+ // state 与本进程 pendingStates 比对防 CSRF,不认识则丢弃令牌。
570
+ if (!code) {
571
+ log.warn('[dsh-agentone] callback rejected: missing code');
572
+ redirect(false);
573
+ return;
574
+ }
575
+ try {
576
+ const result = await exchangeCode(config.platformUrl, code);
577
+ if (!pendingStates.delete(String(result.state || ''))) {
578
+ log.warn('[dsh-agentone] callback rejected: unknown state from exchange');
579
+ redirect(false);
580
+ return;
581
+ }
582
+ const expiresIn = Number(result.expires_in) || 0;
583
+ await saveCredentials(config, {
584
+ schema_version: 2,
585
+ access_token: result.access_token,
586
+ refresh_token: result.refresh_token || null,
587
+ platform_url: config.platformUrl,
588
+ saved_at: new Date().toISOString(),
589
+ expires_at: expiresIn ? new Date(Date.now() + expiresIn * 1000).toISOString() : null,
590
+ });
591
+ log.info('[dsh-agentone] platform login stored');
592
+ // 登录即配置模型 + 同步套餐技能:失败不阻断登录,状态页展示原因
593
+ await syncPlatformModel(config, log).catch(() => {});
594
+ await syncPlanSkills(config, log, {
595
+ ...(await loadCredentials(config)),
596
+ access_token: result.access_token,
597
+ }).catch(() => {});
598
+ redirect(true);
599
+ } catch (error) {
600
+ log.warn(`[dsh-agentone] exchange failed: ${error?.message || error}`);
601
+ redirect(false);
602
+ }
603
+ });
604
+
605
+ register('/agentone/api/model/sync', async (request, response) => {
606
+ if (request.method !== 'POST') {
607
+ response.writeHead(405, { allow: 'POST' });
608
+ response.end();
609
+ return;
610
+ }
611
+ try {
612
+ const result = await syncPlatformModel(config, log);
613
+ sendJson(response, 200, {
614
+ ok: true,
615
+ default_model: result.defaultModel,
616
+ models_count: result.models.length,
617
+ });
618
+ } catch (error) {
619
+ sendJson(response, 502, { error: error?.message || '同步平台模型失败' });
620
+ }
621
+ });
622
+
623
+ register('/agentone/api/skill/install', async (request, response) => {
624
+ if (request.method !== 'POST') {
625
+ response.writeHead(405, { allow: 'POST' });
626
+ response.end();
627
+ return;
628
+ }
629
+ try {
630
+ const credentials = await ensureFreshToken(config, log);
631
+ if (!credentials) {
632
+ sendJson(response, 401, { error: '请先完成飞书登录' });
633
+ return;
634
+ }
635
+ const body = await readJsonBody(request);
636
+ const slug = String(body.slug || '');
637
+ if (!/^[a-z0-9][a-z0-9-]*(--[a-z0-9][a-z0-9-]*)?$/i.test(slug)) {
638
+ sendJson(response, 400, { error: '技能标识格式不正确' });
639
+ return;
640
+ }
641
+ const result = await installPlatformSkill(
642
+ config.platformUrl,
643
+ credentials.access_token,
644
+ slug,
645
+ { config },
646
+ );
647
+ sendJson(response, 200, { ok: true, ...result });
648
+ } catch (error) {
649
+ sendJson(response, 502, { error: error?.message || '技能安装失败' });
650
+ }
651
+ });
652
+
653
+ register('/agentone/api/skill/catalog', async (request, response) => {
654
+ if (request.method !== 'GET') {
655
+ response.writeHead(405, { allow: 'GET' });
656
+ response.end();
657
+ return;
658
+ }
659
+ try {
660
+ const credentials = await ensureFreshToken(config, log);
661
+ if (!credentials) {
662
+ sendJson(response, 401, { error: '请先完成飞书登录' });
663
+ return;
664
+ }
665
+ const query = new URL(request.url, 'http://localhost').searchParams.get('q') || '';
666
+ const resp = await platformFetch(
667
+ `${config.platformUrl.replace(/\/+$/, '')}/api/plugin/v1/skills/catalog?q=${encodeURIComponent(query)}`,
668
+ { headers: { Authorization: `Bearer ${credentials.access_token}` } },
669
+ );
670
+ const data = await resp.json().catch(() => ({}));
671
+ if (!resp.ok) {
672
+ sendJson(response, resp.status, { error: data?.detail || '技能市场暂时不可用' });
673
+ return;
674
+ }
675
+ // 附带已装清单,前端好标「已安装」
676
+ const listing = await listPlatformSkills(
677
+ config.platformUrl,
678
+ credentials.access_token,
679
+ ).catch(() => null);
680
+ const installed = (listing?.skills || []).filter((skill) => skill.installed);
681
+ sendJson(response, 200, { ...data, installed });
682
+ } catch (error) {
683
+ sendJson(response, 502, { error: '技能市场暂时不可用,请稍后再试' });
684
+ }
685
+ });
686
+
687
+ register('/agentone/api/skill/uninstall', async (request, response) => {
688
+ if (request.method !== 'POST') {
689
+ response.writeHead(405, { allow: 'POST' });
690
+ response.end();
691
+ return;
692
+ }
693
+ try {
694
+ const credentials = await loadCredentials(config);
695
+ if (!credentials) {
696
+ sendJson(response, 401, { error: '请先完成飞书登录' });
697
+ return;
698
+ }
699
+ const body = await readJsonBody(request);
700
+ const slug = String(body.slug || '');
701
+ if (!/^[a-z0-9][a-z0-9-]*(--[a-z0-9][a-z0-9-]*)?$/i.test(slug)) {
702
+ sendJson(response, 400, { error: '技能标识格式不正确' });
703
+ return;
704
+ }
705
+ await removePlatformSkill(config.platformUrl, credentials.access_token, slug, { config });
706
+ sendJson(response, 200, { ok: true, canonical_slug: slug });
707
+ } catch (error) {
708
+ sendJson(response, 502, { error: error?.message || '技能卸载失败' });
709
+ }
710
+ });
711
+
712
+ register('/agentone/api/plugin/list', async (request, response) => {
713
+ if (request.method !== 'GET') {
714
+ response.writeHead(405, { allow: 'GET' });
715
+ response.end();
716
+ return;
717
+ }
718
+ try {
719
+ const [state, outdatedStdout] = await Promise.all([
720
+ listProfilePlugins(config),
721
+ pnpmCli(['outdated', '--format', 'list'], 60000)
722
+ .then((r) => r.stdout)
723
+ .catch(() => ''),
724
+ ]);
725
+ const outdated = parseOutdatedList(
726
+ outdatedStdout,
727
+ state.plugins.map((plugin) => plugin.name),
728
+ );
729
+ for (const plugin of state.plugins) {
730
+ const spread = outdated[plugin.name];
731
+ plugin.latest = spread ? spread.latest : null;
732
+ }
733
+ sendJson(response, 200, state);
734
+ } catch (error) {
735
+ sendJson(response, 500, { error: error?.message || '读取插件列表失败' });
736
+ }
737
+ });
738
+
739
+ register('/agentone/api/plugin/install', async (request, response) => {
740
+ if (request.method !== 'POST') {
741
+ response.writeHead(405, { allow: 'POST' });
742
+ response.end();
743
+ return;
744
+ }
745
+ try {
746
+ const body = await readJsonBody(request);
747
+ const state = await installProfilePlugin(
748
+ config,
749
+ String(body.name || ''),
750
+ body.version === undefined ? undefined : String(body.version),
751
+ );
752
+ sendJson(response, 200, { ok: true, ...state });
753
+ } catch (error) {
754
+ sendJson(response, 400, { error: error?.message || '插件安装失败' });
755
+ }
756
+ });
757
+
758
+ register('/agentone/api/plugin/remove', async (request, response) => {
759
+ if (request.method !== 'POST') {
760
+ response.writeHead(405, { allow: 'POST' });
761
+ response.end();
762
+ return;
763
+ }
764
+ try {
765
+ const body = await readJsonBody(request);
766
+ const state = await removeProfilePlugin(config, String(body.name || ''));
767
+ sendJson(response, 200, { ok: true, ...state });
768
+ } catch (error) {
769
+ sendJson(response, 400, { error: error?.message || '插件卸载失败' });
770
+ }
771
+ });
772
+
773
+ register('/agentone/api/plugin/upgrade', async (request, response) => {
774
+ if (request.method !== 'POST') {
775
+ response.writeHead(405, { allow: 'POST' });
776
+ response.end();
777
+ return;
778
+ }
779
+ try {
780
+ const body = await readJsonBody(request);
781
+ const state = await upgradeProfilePlugin(config, String(body.name || ''));
782
+ sendJson(response, 200, { ok: true, ...state });
783
+ } catch (error) {
784
+ sendJson(response, 400, { error: error?.message || '插件升级失败' });
785
+ }
786
+ });
787
+
788
+ register('/agentone/api/cli/status', async (request, response) => {
789
+ if (request.method !== 'GET') {
790
+ response.writeHead(405, { allow: 'GET' });
791
+ response.end();
792
+ return;
793
+ }
794
+ // 真实探测本机 CLI:与平台 cli-deployd 的判定口径一致(tokenStatus
795
+ // valid/needs_refresh = 已授权)。并发探测,单个失败不影响另一个。
796
+ const [lark, ccpg] = await Promise.all([
797
+ probeCliTool('lark-cli').catch(() => 'missing'),
798
+ probeCliTool('ccpg-cli').catch(() => 'missing'),
799
+ ]);
800
+ sendJson(response, 200, { tools: { 'lark-cli': lark, 'ccpg-cli': ccpg } });
801
+ });
802
+
803
+ register('/agentone/api/plan', async (request, response) => {
804
+ if (request.method !== 'GET') {
805
+ response.writeHead(405, { allow: 'GET' });
806
+ response.end();
807
+ return;
808
+ }
809
+ try {
810
+ const credentials = await loadCredentials(config);
811
+ if (!credentials) {
812
+ sendJson(response, 401, { error: '请先完成飞书登录' });
813
+ return;
814
+ }
815
+ const plan = await fetchPlatformPlan(config.platformUrl, credentials.access_token);
816
+ sendJson(response, 200, plan);
817
+ } catch (error) {
818
+ sendJson(response, 502, { error: error?.message || '获取套餐状态失败' });
819
+ }
820
+ });
821
+
822
+ register('/agentone/api/plan/apply', async (request, response) => {
823
+ if (request.method !== 'POST') {
824
+ response.writeHead(405, { allow: 'POST' });
825
+ response.end();
826
+ return;
827
+ }
828
+ try {
829
+ const credentials = await loadCredentials(config);
830
+ if (!credentials) {
831
+ sendJson(response, 401, { error: '请先完成飞书登录' });
832
+ return;
833
+ }
834
+ const body = await readJsonBody(request);
835
+ const planId = String(body.plan_id || '');
836
+ const message = String(body.message || '').slice(0, 500);
837
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$/.test(planId)) {
838
+ sendJson(response, 400, { error: '套餐标识格式不正确' });
839
+ return;
840
+ }
841
+ const result = await applyPlatformPlan(
842
+ config.platformUrl,
843
+ credentials.access_token,
844
+ planId,
845
+ message,
846
+ );
847
+ sendJson(response, 200, result);
848
+ } catch (error) {
849
+ sendJson(response, 400, { error: error?.message || '套餐申请提交失败' });
850
+ }
851
+ });
852
+
853
+ register('/agentone/api/logout', async (request, response) => {
854
+ if (request.method !== 'POST') {
855
+ response.writeHead(405, { allow: 'POST' });
856
+ response.end();
857
+ return;
858
+ }
859
+ try {
860
+ // 先吊销平台侧 refresh token(尽力而为),再清本地;凭据读取失败
861
+ // (文件损坏等)也要走到清凭据,不能把用户卡在登录态。
862
+ const credentials = await loadCredentials(config);
863
+ if (credentials?.refresh_token) {
864
+ await revokePlatformToken(config.platformUrl, credentials.refresh_token);
865
+ }
866
+ await clearCredentials(config);
867
+ await removePlatformModel(config).catch((error) => {
868
+ log.warn(`[dsh-agentone] remove platform model failed: ${error?.message || error}`);
869
+ });
870
+ sendJson(response, 200, { ok: true });
871
+ } catch (error) {
872
+ // 本地清理任何一步失败都明确报错(中文),不再静默挂起连接
873
+ log.warn(`[dsh-agentone] logout failed: ${error?.message || error}`);
874
+ sendJson(response, 500, { error: '退出登录失败,请稍后重试' });
875
+ }
876
+ });
877
+
878
+ // 启动时若已有登录态:先保鲜令牌(临期轮换),再后台刷新模型配置与套餐技能。
879
+ // 内置插件常驻检测与登录无关,独立先行。
880
+ ensureBuiltinPlugins(config, log).catch((error) => {
881
+ log.warn(`[dsh-agentone] builtin plugin check failed: ${error?.message || error}`);
882
+ });
883
+ (async () => {
884
+ const credentials = await ensureFreshToken(config, log);
885
+ if (!credentials) return;
886
+ await syncPlatformModel(config, log).catch(() => {});
887
+ await syncPlanSkills(config, log, credentials).catch(() => {});
888
+ })();
889
+
890
+ return () => {
891
+ pendingStates.clear();
892
+ log.info('[dsh-agentone] routes unmounted');
893
+ };
894
+ }, 'dsh-agentone: http routes');
895
+ }