dsh-agentone 0.5.37 → 0.5.39

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 CHANGED
@@ -1,9 +1,9 @@
1
1
  // dsh-agentone 主入口:挂 /agentone/* 路由(dsh webServer 原生 Node handler)。
2
2
  //
3
- // M1(飞书登录)页面与 API:
3
+ // M1(登录,IDOne 优先、飞书兜底)页面与 API:
4
4
  // GET /agentone/ 状态页(登录/登出/模型同步状态)
5
- // GET /agentone/api/status {platform_url, logged_in, user_email, model}
6
- // POST /agentone/api/login {callback_url} → {login_url}(浏览器打开)
5
+ // GET /agentone/api/status {platform_url, logged_in, user_email, model, login_provider}
6
+ // POST /agentone/api/login {callback_url} → {login_url, provider}(浏览器打开)
7
7
  // GET /agentone/callback?code&state 平台 landing 跳回:换令牌后自动配置模型
8
8
  // POST /agentone/api/logout 清除本地凭据并移除平台模型配置
9
9
  // POST /agentone/api/model/sync 重新拉取平台模型并写入 settings.yaml
@@ -21,24 +21,29 @@ import {
21
21
  applyPlatformPlan,
22
22
  buildLoginUrl,
23
23
  exchangeCode,
24
+ fetchAuthCapabilities,
24
25
  fetchPlatformCapabilities,
25
26
  fetchPlatformPlan,
26
27
  fetchPlatformProfile,
27
28
  newLoginState,
29
+ pickLoginProvider,
28
30
  refreshPlatformToken,
29
31
  revokePlatformToken,
30
32
  } from './auth.js';
31
33
  import { applyPlatformModel, removePlatformModel } from './model.js';
32
34
  import { installPlatformSkill, listPlatformSkills, removePlatformSkill } from './skill.js';
33
35
  import { applyPlatformSkillhub, removePlatformSkillhub } from './skillhub.js';
36
+ import { createProfilePlugins, validatePackage, PROTECTED_PACKAGES } from './profile-plugins.js';
37
+ import { createDefaultsSync } from './defaults-sync.js';
38
+ import { canonicalSkillSlug, localSkillState } from './skill-state.js';
34
39
  import { ensureLanguageDefault } from './language.js';
35
40
  import { bootstrapInstanceCredentials, INSTANCE_SOURCE, isInstanceManaged, resolvePlatformUrl } from './instance.js';
36
41
  import { beginCliAuth, cliAuthQrcode, completeCliAuth, parseCliAuthBeginOutput } from './cliauth.mjs';
37
42
  import { platformFetch } from './http.js';
38
43
  import { donePageHtml, statusPageHtml } from './page.js';
39
- import { compareSemver, dshPluginCli, firstErrorLines, hasChineseText, installCliTool, isTerminalCliAuthFailure, isUnexpectedStoreError, larkCliInitActiveUrl, larkCliInitStatus, probeCliDetail, probePythonEnv, pnpmViewLatest, runCliTool, startLarkCliInit, stopLarkCliInit, unexpectedStoreGuidance, upgradeCliTool } from './proc.js';
44
+ import { compareSemver, firstErrorLines, hasChineseText, installCliTool, isTerminalCliAuthFailure, isUnexpectedStoreError, larkCliInitActiveUrl, larkCliInitStatus, probeCliDetail, probePythonEnv, pnpmViewLatest, runCliTool, startLarkCliInit, stopLarkCliInit, unexpectedStoreGuidance, upgradeCliTool } from './proc.js';
40
45
 
41
- import { clearCredentials, loadCredentials, resolveDshHome, saveCredentials, updateCredentials } from './store.js';
46
+ import { clearCredentials, loadCredentials, saveCredentials, updateCredentials } from './store.js';
42
47
 
43
48
  const require = createRequire(import.meta.url);
44
49
 
@@ -56,11 +61,35 @@ export const Config = z.object({
56
61
  .default('https://ccpg.one.agentone.work')
57
62
  .description('AgentOne 平台地址(本地开发在 profile cordis.patch.yml 覆盖为 http://127.0.0.1:8558)'),
58
63
  storeDir: z.string().default('').description('凭据存储目录,空则用 $DSH_HOME/agentone'),
64
+ profileName: z.string().default('').description('当前 Web profile 名称;Desktop 自动读取当前 profile,旧版 Web 宿主请明确填写'),
59
65
  });
60
66
 
61
67
  /** 本次进程内的登录 CSRF state(/callback 比对后即失效)。 */
62
68
  const pendingStates = new Set();
63
69
 
70
+ // 登录入口判定缓存:key = platformUrl(避免切换平台后沿用旧判定)。
71
+ // 成功缓存 5 分钟(平台配置热重载后慢收敛可接受);查询失败只缓存 30 秒
72
+ // (避免平台瞬时抖动把入口钉死在飞书,也不让未登录状态轮询反复吃超时)。
73
+ const PROVIDER_CACHE_OK_MS = 5 * 60 * 1000;
74
+ const PROVIDER_CACHE_FAIL_MS = 30 * 1000;
75
+ const loginProviderCache = new Map();
76
+
77
+ /** 查平台能力面决定登录入口('idone' | 'feishu'),永抛错、永阻断登录。 */
78
+ async function resolveLoginProvider(platformUrl, log) {
79
+ const key = String(platformUrl || '').replace(/\/+$/, '');
80
+ const cached = loginProviderCache.get(key);
81
+ if (cached && Date.now() - cached.at < cached.ttl) return cached.provider;
82
+ const caps = await fetchAuthCapabilities(key);
83
+ const provider = pickLoginProvider(caps);
84
+ loginProviderCache.set(key, {
85
+ provider,
86
+ at: Date.now(),
87
+ ttl: caps ? PROVIDER_CACHE_OK_MS : PROVIDER_CACHE_FAIL_MS,
88
+ });
89
+ if (!caps) log?.warn?.(`[dsh-agentone] capabilities unavailable, login falls back to feishu: ${key}`);
90
+ return provider;
91
+ }
92
+
64
93
  function sendJson(response, status, data) {
65
94
  response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
66
95
  response.end(JSON.stringify(data));
@@ -189,13 +218,6 @@ async function ensureFreshToken(config, log) {
189
218
  return tokenRefreshInFlight;
190
219
  }
191
220
 
192
- /**
193
- * 插件管理目标 profile:普通 dsh 固定 web(控制台只跑 web profile);
194
- * DSH Desktop 用 desktopProfiles.current(apply 时探测填充)。
195
- */
196
- let PLUGIN_PROFILE = 'web';
197
- /** desktop 受管插件操作通道(desktopPnpm.runPlugin 包装),非 desktop 为 null。 */
198
- let desktopPluginCli = null;
199
221
  /**
200
222
  * dsh ≥ 0.1.7 的官方 settings 服务(apply 时可选获取):用来把平台模型写进
201
223
  * profile patch 活配置层,同步即时生效。旧宿主没有该服务,取到 null 即回退
@@ -203,10 +225,6 @@ let desktopPluginCli = null;
203
225
  */
204
226
  let settingsService = null;
205
227
 
206
- function pluginProfileDir(config) {
207
- return join(resolveDshHome(config), 'profiles', PLUGIN_PROFILE);
208
- }
209
-
210
228
  /** 模型同步失败自愈重试调度(模块级单例:apply 重入先清旧定时器)。
211
229
  * 平台侧瞬时故障(如 AirRouter 短暂不可达)会让启动同步失败,错误一直
212
230
  * 挂在状态页直到下次 dsh 重启——这里由 5s 状态轮询驱动:发现 model_error
@@ -242,14 +260,6 @@ function scheduleModelRetry(config, log) {
242
260
  modelRetryTimer.unref?.();
243
261
  }
244
262
 
245
- /** 转发 dsh plugin 子命令:desktop 走受管 pnpm,普通 dsh 走官方 CLI。 */
246
- async function runPluginCli(args) {
247
- if (desktopPluginCli) {
248
- return desktopPluginCli(args);
249
- }
250
- return dshPluginCli(args, PLUGIN_PROFILE);
251
- }
252
-
253
263
  /** 宿主 DesktopPnpmService 的并发锁占用报错(同一时刻只允许一个 pnpm
254
264
  * 操作——设计如此,不是故障)。识别后应排队等待而非把错误抛给用户。 */
255
265
  const DESKTOP_PNPM_BUSY_RE = /another desktop pnpm operation is already running/i;
@@ -275,13 +285,14 @@ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
275
285
  */
276
286
  export function desktopRunPlugin(service, invokingDir) {
277
287
  let queue = Promise.resolve();
278
- const run = async (args) => {
288
+ const run = async (args, options = {}) => {
279
289
  // 宿主 lock 是同步抛错(runPlugin 调用即抛),先探再跑 + 失败退避都做
280
290
  let attempt = 0;
281
291
  for (;;) {
292
+ options.signal?.throwIfAborted();
282
293
  let handle;
283
294
  try {
284
- handle = service.runPlugin([...args], invokingDir);
295
+ handle = service.runPlugin([...args], invokingDir, options.signal);
285
296
  } catch (error) {
286
297
  const message = error?.message || String(error);
287
298
  if (DESKTOP_PNPM_BUSY_RE.test(message) && attempt < 5) {
@@ -291,6 +302,9 @@ export function desktopRunPlugin(service, invokingDir) {
291
302
  }
292
303
  throw error;
293
304
  }
305
+ const cancel = () => handle.cancel();
306
+ options.signal?.addEventListener('abort', cancel, { once: true });
307
+ if (options.signal?.aborted) cancel();
294
308
  let stdout = '';
295
309
  let stderr = '';
296
310
  const append = (acc, chunk) => (acc + String(chunk)).slice(-64 * 1024);
@@ -304,6 +318,7 @@ export function desktopRunPlugin(service, invokingDir) {
304
318
  }, 300_000);
305
319
  try {
306
320
  const outcome = await handle.done;
321
+ options.signal?.throwIfAborted();
307
322
  if (timedOut) throw new Error('命令执行超时,请稍后重试');
308
323
  if (outcome.exitCode !== 0 || outcome.signal != null) {
309
324
  const message = firstErrorLines(`${stderr}\n${stdout}`);
@@ -325,35 +340,18 @@ export function desktopRunPlugin(service, invokingDir) {
325
340
  return { stdout, stderr };
326
341
  } finally {
327
342
  clearTimeout(timer);
343
+ options.signal?.removeEventListener('abort', cancel);
328
344
  }
329
345
  }
330
346
  };
331
- return (args) => {
332
- const next = queue.then(() => run(args));
347
+ return (args, options = {}) => {
348
+ const next = queue.then(() => run(args, options));
333
349
  // 队列尾吞错,避免一次失败卡死后续排队操作
334
350
  queue = next.catch(() => undefined);
335
351
  return next;
336
352
  };
337
353
  }
338
354
 
339
- /**
340
- * npm 包名白名单(scoped 与非 scoped)。用户输入会进入 CLI 参数,必须先
341
- * 收敛到 npm 官方命名规则,杜绝以 `-` 开头的值被 dsh/pnpm 当成 flag。
342
- */
343
- const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
344
- /** 版本白名单:语义化版本、^ ~ 前缀与 latest 标签。 */
345
- const VERSION_RE = /^(?:\^|~)?\d+(?:\.\d+){0,3}(?:[-+][a-z0-9._-]+)?$|^latest$/;
346
-
347
- /** 校验用户输入的包名/版本;非法值直接拒绝(中文报错)。 */
348
- function assertValidPackage(name, version) {
349
- if (!NPM_NAME_RE.test(name)) {
350
- throw new Error('插件名不合法:仅允许小写字母、数字和 . _ -(scoped 名如 @scope/pkg)');
351
- }
352
- if (version !== undefined && version !== '' && !VERSION_RE.test(version)) {
353
- throw new Error('版本号不合法:仅允许语义化版本(如 1.2.3、^1.2.0)或 latest');
354
- }
355
- }
356
-
357
355
  async function readJsonFile(path) {
358
356
  try {
359
357
  return JSON.parse(await readFile(path, 'utf8'));
@@ -368,19 +366,6 @@ async function installedVersion(profile, name) {
368
366
  return typeof manifest?.version === 'string' ? manifest.version : null;
369
367
  }
370
368
 
371
- /** 包是否带 dsh.bundle 声明(决定它在层列表里是否被激活)。 */
372
- async function isBundlePackage(profile, name) {
373
- const manifest = await readJsonFile(join(profile, 'node_modules', name, 'package.json'));
374
- return Boolean(manifest?.dsh?.bundle);
375
- }
376
-
377
- /** link:/file: 等本地路径依赖展示短标签(没有版本号可读)。 */
378
- function specLabel(spec) {
379
- if (typeof spec !== 'string') return '';
380
- if (spec.startsWith('link:') || spec.startsWith('file:')) return '本地链接';
381
- return '';
382
- }
383
-
384
369
  /** dsh 插件与 CLI 操作白名单:用户输入不得进入 execFile 的 tool 位。 */
385
370
  const CLI_TOOLS = new Set(['lark-cli', 'ccpg-cli']);
386
371
  /** 除 CLI 外可通过 /agentone/api/cli/install 自助安装的工具(python 无
@@ -423,7 +408,7 @@ const INBOX_BUNDLES = new Set([
423
408
  * 装入 /opt/prebuilt/dsh-home,init.sh 首启物化到持久卷)。用户可以卸载,
424
409
  * 但它们承载控制台核心体验(侧栏宿主/Workflow One/插件市场/IM 桥),所以
425
410
  * 每次 dsh 启动时检测:缺失则静默重装(常驻保证),已装则不碰(尊重用户
426
- * 的版本选择)。
411
+ * 的版本选择)。安装走 profile-plugins 受管通道,与平台默认能力同一队列。
427
412
  */
428
413
  const BUILTIN_PLUGINS = [
429
414
  'dsh-better-sidebar',
@@ -451,206 +436,18 @@ const BUILTIN_PLUGIN_DESCRIPTIONS = {
451
436
  'dsh-im':
452
437
  'IM 桥核心:让 dsh 接入飞书等聊天工具——群内 @机器人 干活、收通知与审批卡片',
453
438
  [PLUGIN_PACKAGE_NAME]:
454
- 'AgentOne 平台集成:飞书登录、平台模型、SkillHub 技能与 CLI 授权(本插件)',
439
+ 'AgentOne 平台集成:IDOne 登录、平台模型、SkillHub 技能与 CLI 授权(本插件)',
455
440
  };
456
441
 
457
- /** 汇总 profile 插件状态:dependencies(去掉 in-box)+ 已装版本 + 激活态。 */
458
- async function listProfilePlugins(config) {
459
- const dir = pluginProfileDir(config);
460
- const manifest = await readJsonFile(join(dir, 'package.json'));
461
- const dependencies = manifest?.dependencies ?? {};
462
- const bundles = Array.isArray(manifest?.dsh?.profile?.bundles)
463
- ? manifest.dsh.profile.bundles
464
- : [];
465
- const plugins = [];
466
- const seen = new Set();
467
- for (const [name, spec] of Object.entries(dependencies)) {
468
- if (INBOX_BUNDLES.has(name)) continue;
469
- seen.add(name);
470
- const version = await installedVersion(dir, name);
471
- const bundle = version !== null && (await isBundlePackage(dir, name));
472
- plugins.push({
473
- name,
474
- spec,
475
- version,
476
- source: specLabel(spec) || 'npm',
477
- bundled: bundle,
478
- // 激活 = 已装 + 带 bundle 声明 + 出现在 dsh.profile.bundles
479
- active: bundle && bundles.includes(name),
480
- builtin: BUILTIN_PLUGINS.includes(name),
481
- // 自身随平台镜像升级:前端据此隐藏卸载/升级按钮(与后端拒绝逻辑同源)
482
- self_managed: name === PLUGIN_PACKAGE_NAME && isSelfManaged(),
483
- description: BUILTIN_PLUGIN_DESCRIPTIONS[name] || null,
484
- });
485
- }
486
- // 内置插件被卸载后不在 dependencies 里,但仍要出现在列表中
487
- // (状态「未安装」+「安装」按钮),否则用户无从感知与恢复。
488
- for (const name of BUILTIN_PLUGINS) {
489
- if (seen.has(name)) continue;
490
- plugins.push({
491
- name,
492
- spec: null,
493
- version: null,
494
- source: 'npm',
495
- bundled: false,
496
- active: false,
497
- builtin: true,
498
- self_managed: false,
499
- description: BUILTIN_PLUGIN_DESCRIPTIONS[name] || null,
500
- });
501
- }
502
- return {
503
- profile: PLUGIN_PROFILE,
504
- bundles: bundles.filter((name) => !INBOX_BUNDLES.has(name)),
505
- plugins,
506
- };
507
- }
508
-
509
- /** dsh plugin add 的构建脚本放行参数(与镜像 Dockerfile 预装命令同源)。
510
- * pnpm 11 默认拦截依赖构建脚本,node-pty(sidebar/harness-one 的终端依赖)
511
- * 被拦会导致 add 非零退出、bundles reconcile 不执行——包装上了却永远
512
- * 「未激活」。用 = 连接形式,避免空格分隔被 pnpm 拆成两个参数。
513
- */
442
+ /** 内置插件安装期构建授权(与镜像 Dockerfile 预装命令同源):pnpm 默认
443
+ * 拦截依赖构建脚本,node-pty(侧栏/工作台的终端依赖)被拦会导致插件
444
+ * 装上却无法激活。平台默认能力清单未声明时按此兜底合并。 */
514
445
  const BUILTIN_PLUGIN_ALLOW_BUILD = {
515
- 'dsh-better-sidebar': ['--allow-build=node-pty'],
516
- 'dsh-harness-one': ['--allow-build=node-pty'],
517
- dshmarket: ['--allow-build=node-pty'],
518
- 'dsh-im': [],
519
- [PLUGIN_PACKAGE_NAME]: [],
446
+ 'dsh-better-sidebar': ['node-pty'],
447
+ 'dsh-harness-one': ['node-pty'],
448
+ dshmarket: ['node-pty'],
520
449
  };
521
450
 
522
- /** 安装:白名单校验后转发(desktop 受管 pnpm / 官方 CLI;参数数组,无 shell)。
523
- * pnpm 11 在 profile 存在被忽略的构建脚本时 add 以 ERR_PNPM_IGNORED_BUILDS
524
- * 非零退出,但包本身已装上(先装后报)——以落盘为准,装上即成功。 */
525
- async function installProfilePlugin(config, name, version) {
526
- assertValidPackage(name, version);
527
- const target = version ? `${name}@${version}` : name;
528
- try {
529
- await runPluginCli(['add', target, ...(BUILTIN_PLUGIN_ALLOW_BUILD[name] || [])]);
530
- } catch (error) {
531
- if ((await installedVersion(pluginProfileDir(config), name)) === null) throw error;
532
- }
533
- return listProfilePlugins(config);
534
- }
535
-
536
- /** 卸载:自身与内置插件拒绝(无卸载按钮 + 后端兜底);白名单校验后转发官方 CLI。 */
537
- async function removeProfilePlugin(config, name) {
538
- assertValidPackage(name);
539
- if (INBOX_BUNDLES.has(name)) {
540
- throw new Error('dsh 内置组件不可卸载');
541
- }
542
- if (BUILTIN_PLUGINS.includes(name)) {
543
- throw new Error('内置插件承载 dsh 核心体验,不可卸载');
544
- }
545
- if (name === PLUGIN_PACKAGE_NAME) {
546
- throw new Error('AgentOne 插件是登录与模型配置的载体,不能卸载自己');
547
- }
548
- await runPluginCli(['remove', name]);
549
- return listProfilePlugins(config);
550
- }
551
-
552
- /** 升级到 registry 最新版:view 查实时 latest → 显式版本号 add(会重写
553
- * package.json spec)→ 校验落盘版本。不能用 `dsh plugin update`(即
554
- * `pnpm update`):它只在 spec range 内升级,`dsh plugin add` 写入的精确
555
- * pin(如 "0.19.1")下永远是 no-op,却返回成功——历史上「点升级提示已
556
- * 升级、版本永不变」的根因。 */
557
- async function upgradeProfilePlugin(config, name) {
558
- assertValidPackage(name);
559
- if (INBOX_BUNDLES.has(name)) {
560
- throw new Error('dsh 内置组件随 dsh 一同升级,请等待平台更新 dsh');
561
- }
562
- if (name === PLUGIN_PACKAGE_NAME && isSelfManaged()) {
563
- throw new Error('AgentOne 插件随平台镜像一同升级,无需手动操作');
564
- }
565
- const dir = pluginProfileDir(config);
566
- const [latest, current] = await Promise.all([
567
- pnpmViewLatest(name, dir).catch(() => null),
568
- installedVersion(dir, name),
569
- ]);
570
- if (!latest) {
571
- throw new Error('查询插件最新版本失败,请稍后重试');
572
- }
573
- // latest dist-tag 可能指向旧版(作者误指/回退),不比已装高就没必要升
574
- if (latest === current || (current && compareSemver(latest, current) <= 0)) {
575
- throw new Error(`已是最新版本(v${current ?? latest})`);
576
- }
577
- try {
578
- await runPluginCli(['add', `${name}@${latest}`, ...(BUILTIN_PLUGIN_ALLOW_BUILD[name] || [])]);
579
- } catch (error) {
580
- // 同 install:ERR_PNPM_IGNORED_BUILDS 非零退出但包已装上,不阻断升级
581
- if ((await installedVersion(dir, name)) !== latest) throw error;
582
- }
583
- const after = await installedVersion(dir, name);
584
- if (after !== latest) {
585
- // 兑现「插件已升级」提示的前置校验:没升上去就明说,不再假报成功
586
- throw new Error(`升级未生效(当前 v${after ?? '未安装'},目标 v${latest}),请稍后重试`);
587
- }
588
- return listProfilePlugins(config);
589
- }
590
-
591
- /**
592
- * 内置插件常驻检测:镜像预装插件缺失时逐个静默重装。单个失败不阻断
593
- * (网络抖动等),下一个启动周期再试;全部已装时零开销。
594
- */
595
- async function ensureBuiltinPlugins(config, log) {
596
- const state = await listProfilePlugins(config);
597
- const installed = new Set(state.plugins.map((plugin) => plugin.name));
598
- const missing = BUILTIN_PLUGINS.filter((name) => !installed.has(name));
599
- if (!missing.length) return [];
600
- const reinstalled = [];
601
- for (const name of missing) {
602
- try {
603
- await installProfilePlugin(config, name);
604
- reinstalled.push(name);
605
- log.info(`[dsh-agentone] builtin plugin reinstalled: ${name}`);
606
- } catch (error) {
607
- log.warn(`[dsh-agentone] builtin plugin reinstall failed (${name}): ${error?.message || error}`);
608
- }
609
- }
610
- return reinstalled;
611
- }
612
-
613
- /**
614
- * 套餐技能自动同步:平台上 source=plan 且未安装的技能静默装齐(与平台托管
615
- * 实例的套餐技能预装语义对齐);单个失败不阻断其余,结果记 credentials。
616
- */
617
- async function syncPlanSkills(config, log, credentials) {
618
- const token = credentials?.access_token;
619
- if (!token) return null;
620
- let listing;
621
- try {
622
- listing = await listPlatformSkills(config.platformUrl, token);
623
- } catch (error) {
624
- log.warn(`[dsh-agentone] skills list failed: ${error?.message || error}`);
625
- await updateCredentials(config, { skills_error: String(error?.message || error) }).catch(
626
- () => {},
627
- );
628
- return null;
629
- }
630
- const pending = (listing.skills || []).filter((skill) => skill.source === 'plan' && !skill.installed);
631
- let installed = 0;
632
- const failures = [];
633
- for (const skill of pending) {
634
- try {
635
- await installPlatformSkill(config.platformUrl, token, skill.canonical_slug, {
636
- config,
637
- source: 'plan',
638
- });
639
- installed += 1;
640
- log.info(`[dsh-agentone] plan skill installed: ${skill.canonical_slug}`);
641
- } catch (error) {
642
- failures.push(`${skill.canonical_slug}: ${error?.message || error}`);
643
- }
644
- }
645
- await updateCredentials(config, {
646
- skills_synced_at: new Date().toISOString(),
647
- skills_error: failures.length ? failures.join(';') : null,
648
- }).catch(() => {});
649
- if (failures.length) log.warn(`[dsh-agentone] plan skill failures: ${failures.join(';')}`);
650
- return { installed, failed: failures.length };
651
- }
652
-
653
-
654
451
  /**
655
452
  * status 内缓存:skills/catalog 内容几乎不变,却被 5s 轮询每次全量重拉。
656
453
  * 缓存 45s;模型同步结果本就落盘在 credentials,不受缓存影响。
@@ -702,7 +499,7 @@ async function cachedPlatformStatus(config, credentials) {
702
499
  * token + state 比对),收完第一个请求即关停;5 分钟无回调自动超时关闭。
703
500
  * 返回外部浏览器可达的回调地址。
704
501
  */
705
- function openCallbackServer(config, log) {
502
+ function openCallbackServer(config, log, onLogin = () => {}, isCurrent = () => true) {
706
503
  return new Promise((resolve, reject) => {
707
504
  const server = createServer(async (req, res) => {
708
505
  const close = () => server.close();
@@ -733,6 +530,7 @@ button{font:inherit;padding:9px 22px;border-radius:10px;border:none;cursor:point
733
530
  finish(false);
734
531
  return;
735
532
  }
533
+ if (!isCurrent()) { finish(false); return; }
736
534
  const expiresIn = Number(result.expires_in) || 0;
737
535
  await saveCredentials(config, {
738
536
  schema_version: 2,
@@ -749,10 +547,8 @@ button{font:inherit;padding:9px 22px;border-radius:10px;border:none;cursor:point
749
547
  ...(await loadCredentials(config)),
750
548
  access_token: result.access_token,
751
549
  }).catch(() => {});
752
- await syncPlanSkills(config, log, {
753
- ...(await loadCredentials(config)),
754
- access_token: result.access_token,
755
- }).catch(() => {});
550
+ if (!isCurrent()) { finish(false); return; }
551
+ onLogin();
756
552
  finish(true);
757
553
  } catch (error) {
758
554
  log.warn(`[dsh-agentone] desktop exchange failed: ${error?.message || error}`);
@@ -796,14 +592,96 @@ export function apply(ctx, config) {
796
592
  // patch 活配置层;取不到(旧宿主)就回退「只写 settings.yaml + 下次启动导入」。
797
593
  // 不能写进 inject:那会让插件在旧宿主上一直 PENDING。
798
594
  settingsService = ctx.get?.('settings') ?? null;
799
- if (desktopProfiles?.current?.name) {
800
- PLUGIN_PROFILE = desktopProfiles.current.name;
801
- ctx.inject(['desktopPnpm'], (desktopCtx) => {
802
- desktopPluginCli = desktopRunPlugin(desktopCtx.desktopPnpm, pluginProfileDir(config));
803
- log.info(`[dsh-agentone] desktop profile: ${PLUGIN_PROFILE}`);
804
- });
805
- }
806
595
  ctx.effect(() => {
596
+ let disposed = false;
597
+ const profiles = createProfilePlugins({ ctx, config, desktopRun: desktopRunPlugin });
598
+ const defaults = createDefaultsSync({ config, profiles,
599
+ credentials: () => ensureFreshToken(config, log), log });
600
+ const listProfilePlugins = async () => {
601
+ const state = await profiles.list();
602
+ const seen = new Set();
603
+ state.plugins.forEach((plugin) => {
604
+ seen.add(plugin.name);
605
+ plugin.self_managed = plugin.name === PLUGIN_PACKAGE_NAME && isSelfManaged();
606
+ plugin.builtin = plugin.builtin || BUILTIN_PLUGINS.includes(plugin.name);
607
+ plugin.description = plugin.description || BUILTIN_PLUGIN_DESCRIPTIONS[plugin.name] || null;
608
+ });
609
+ // 内置插件被卸载后不在 dependencies 里,仍以「未安装」占位出现在列表,
610
+ // 否则用户无从感知与恢复(常驻检测会在下次启动时自动重装)。
611
+ for (const name of BUILTIN_PLUGINS) {
612
+ if (seen.has(name)) continue;
613
+ state.plugins.push({ name, spec: null, version: null, bundled: false, registered: false,
614
+ active: false, source: 'npm', builtin: true,
615
+ self_managed: name === PLUGIN_PACKAGE_NAME && isSelfManaged(),
616
+ description: BUILTIN_PLUGIN_DESCRIPTIONS[name] || null, target_version: null,
617
+ state: 'missing', loaded: null });
618
+ }
619
+ return state;
620
+ };
621
+ const installProfilePlugin = async (_config, name, version = 'latest') => {
622
+ validatePackage(name, version);
623
+ // 内置插件(镜像控制台四件套/自身)是本机概念,不依赖平台登录:
624
+ // 直接走受管安装队列,构建授权用镜像预装命令同源的 node-pty 兜底。
625
+ if (BUILTIN_PLUGINS.includes(name)) {
626
+ await profiles.install(name, 'latest', { allowBuild: BUILTIN_PLUGIN_ALLOW_BUILD[name] || [] });
627
+ return listProfilePlugins();
628
+ }
629
+ await defaults.manualOperation(async (_owner, options) => {
630
+ const policy = options.document.plugins.find((item) => item.name === name);
631
+ // 平台清单声明的构建授权与内置插件已知的 node-pty 兜底合并
632
+ const allowBuild = [...new Set([...(policy?.allow_build || []), ...(BUILTIN_PLUGIN_ALLOW_BUILD[name] || [])])];
633
+ return profiles.install(name, 'latest', { ...options, allowBuild });
634
+ });
635
+ return listProfilePlugins();
636
+ };
637
+ const removeProfilePlugin = async (_config, name) => {
638
+ validatePackage(name);
639
+ if (PROTECTED_PACKAGES.has(name)) throw new Error('AgentOne 自身和 DSH 基础组件不可卸载');
640
+ if (BUILTIN_PLUGINS.includes(name)) throw new Error('内置插件承载 dsh 控制台核心体验,不可卸载');
641
+ return defaults.manualOperation((_owner, options) => profiles.remove(name, options));
642
+ };
643
+ const upgradeProfilePlugin = async (_config, name) => {
644
+ if (INBOX_BUNDLES.has(name) || (name === PLUGIN_PACKAGE_NAME && isSelfManaged())) {
645
+ throw new Error('此组件随宿主或平台更新,请等待平台更新');
646
+ }
647
+ await defaults.manualOperation(async (_owner, options) => {
648
+ const managed = options.document.plugins.find((item) => item.name === name);
649
+ if (managed) return profiles.install(name, managed.version, { ...options, allowBuild: managed.allow_build });
650
+ const latest = await pnpmViewLatest(name, profiles.target().dir).catch(() => null);
651
+ if (!latest) throw new Error('查询插件最新版本失败,请检查 npm 连接后重试');
652
+ const installed = (await profiles.list()).plugins.find((item) => item.name === name);
653
+ if (installed?.version && compareSemver(latest, installed.version) <= 0) {
654
+ throw new Error(`已是最新版本(v${installed.version})`);
655
+ }
656
+ return profiles.install(name, latest, options);
657
+ });
658
+ return listProfilePlugins();
659
+ };
660
+ const syncPlanSkills = async () => {
661
+ const result = await defaults.sync({ manual: true });
662
+ if (result?.error) throw new Error(result.error);
663
+ invalidateStatusCache();
664
+ return { installed: result?.skills.filter((item) => item.state === 'current').length || 0,
665
+ failed: result?.skills.filter((item) => ['failed', 'conflict', 'report_pending'].includes(item.state)).length || 0 };
666
+ };
667
+ const onLogin = () => {
668
+ defaults.reset();
669
+ void defaults.sync().then(invalidateStatusCache).catch(() => {});
670
+ };
671
+ // 内置插件常驻检测:缺失时静默重装(不依赖登录态);单个失败不阻断,
672
+ // 下个启动周期再试。已装不碰——版本升级交给平台默认能力同步。
673
+ const ensureBuiltinPlugins = async () => {
674
+ const state = await profiles.list();
675
+ const installed = new Set(state.plugins.filter((plugin) => plugin.version).map((plugin) => plugin.name));
676
+ for (const name of BUILTIN_PLUGINS.filter((entry) => !installed.has(entry))) {
677
+ try {
678
+ await profiles.install(name, 'latest', { allowBuild: BUILTIN_PLUGIN_ALLOW_BUILD[name] || [] });
679
+ log.info(`[dsh-agentone] builtin plugin reinstalled: ${name}`);
680
+ } catch (error) {
681
+ log.warn(`[dsh-agentone] builtin plugin reinstall failed (${name}): ${error?.message || error}`);
682
+ }
683
+ }
684
+ };
807
685
  // 模块级重试定时器跨 apply 存活:重入先清,避免旧定时器引用旧 config
808
686
  clearModelRetry();
809
687
  modelRetryAttempts = 0;
@@ -880,16 +758,32 @@ export function apply(ctx, config) {
880
758
  modelRetryAttempts = 0;
881
759
  }
882
760
  }
761
+ if (skills?.skills && defaults.snapshot().user_id) {
762
+ const identity = defaults.snapshot();
763
+ skills = { ...skills, skills: await Promise.all(skills.skills.map(async (item) => {
764
+ const local = await localSkillState(config.platformUrl, item.canonical_slug, {
765
+ config: { ...config, dshHome: identity.home }, userId: identity.user_id, registry: identity.registry,
766
+ });
767
+ return { ...item, installed: ['current', 'report_pending'].includes(local.state),
768
+ local_state: local.state, installed_version: local.record?.version || '',
769
+ upgrade_available: local.state === 'current' && Boolean(item.latest_version && item.latest_fingerprint)
770
+ && (local.record.version !== item.latest_version || local.record.fingerprint !== item.latest_fingerprint) };
771
+ })) };
772
+ }
773
+ // 登录入口判定:UI 登录卡文案需要;托管实例不显示登录入口,跳过查询
774
+ const loginProvider = isInstanceManaged() ? 'feishu' : await resolveLoginProvider(config.platformUrl, log);
883
775
  sendJson(response, 200, {
884
776
  platform_url: config.platformUrl,
885
777
  logged_in: Boolean(userEmail),
886
778
  user_email: userEmail,
779
+ login_provider: loginProvider,
887
780
  // 托管实例:凭据由平台注入,页面据此显示「平台托管」并隐藏登录/登出
888
781
  managed: isInstanceManaged(),
889
782
  source: credentials?.source ?? null,
890
783
  instance_id: credentials?.instance_id ?? null,
891
784
  token_expires_at: credentials?.expires_at ?? null,
892
785
  plugin_version: PLUGIN_VERSION,
786
+ defaults: defaults.snapshot(),
893
787
  capabilities,
894
788
  skills,
895
789
  skill_display_names: displayNames,
@@ -932,7 +826,7 @@ export function apply(ctx, config) {
932
826
  // /agentone/callback 会 403。desktop 环境(服务端自检 execPath 或页面
933
827
  // 传 desktop 标记)改由插件在本进程起一次性本地服务收回调。
934
828
  const callbackUrl = body.desktop || isDesktopRuntime
935
- ? await openCallbackServer(config, log)
829
+ ? await openCallbackServer(config, log, onLogin, () => !disposed)
936
830
  : String(body.callback_url || '');
937
831
  if (!/^https?:\/\/|^\/portal\//.test(callbackUrl)) {
938
832
  sendJson(response, 400, { error: '回调地址不合法' });
@@ -940,7 +834,12 @@ export function apply(ctx, config) {
940
834
  }
941
835
  const state = newLoginState();
942
836
  pendingStates.add(state);
943
- sendJson(response, 200, { login_url: buildLoginUrl(config.platformUrl, callbackUrl, state) });
837
+ // 入口按平台能力面路由:IDOne(锚点模式)优先,飞书兜底
838
+ const provider = await resolveLoginProvider(config.platformUrl, log);
839
+ sendJson(response, 200, {
840
+ login_url: buildLoginUrl(config.platformUrl, callbackUrl, state, provider),
841
+ provider,
842
+ });
944
843
  } catch (error) {
945
844
  log.warn(`[dsh-agentone] login failed: ${error?.message || error}`);
946
845
  sendJson(response, 500, { error: '发起登录失败' });
@@ -975,6 +874,7 @@ export function apply(ctx, config) {
975
874
  redirect(false);
976
875
  return;
977
876
  }
877
+ if (disposed) { redirect(false); return; }
978
878
  const expiresIn = Number(result.expires_in) || 0;
979
879
  await saveCredentials(config, {
980
880
  schema_version: 2,
@@ -991,10 +891,8 @@ export function apply(ctx, config) {
991
891
  ...(await loadCredentials(config)),
992
892
  access_token: result.access_token,
993
893
  }).catch(() => {});
994
- await syncPlanSkills(config, log, {
995
- ...(await loadCredentials(config)),
996
- access_token: result.access_token,
997
- }).catch(() => {});
894
+ if (disposed) { redirect(false); return; }
895
+ onLogin();
998
896
  redirect(true);
999
897
  } catch (error) {
1000
898
  log.warn(`[dsh-agentone] exchange failed: ${error?.message || error}`);
@@ -1002,6 +900,33 @@ export function apply(ctx, config) {
1002
900
  }
1003
901
  });
1004
902
 
903
+ register('/agentone/api/defaults/status', (request, response) => {
904
+ if (request.method !== 'GET') { sendJson(response, 405, { error: '请求方法不正确' }); return; }
905
+ sendJson(response, 200, defaults.snapshot());
906
+ });
907
+ for (const action of ['sync', 'retry', 'apply']) {
908
+ register(`/agentone/api/defaults/${action}`, async (request, response) => {
909
+ if (request.method !== 'POST') { sendJson(response, 405, { error: '请求方法不正确' }); return; }
910
+ try {
911
+ const body = await readJsonBody(request);
912
+ const credentials = await ensureFreshToken(config, log);
913
+ if (!credentials) { sendJson(response, 401, { error: '请先登录 AgentOne 平台' }); return; }
914
+ let item = null;
915
+ if (action !== 'sync') {
916
+ if (!['skill', 'plugin'].includes(body.kind) || typeof body.name !== 'string') {
917
+ sendJson(response, 400, { error: '请选择有效的技能或插件' }); return;
918
+ }
919
+ if (action === 'apply' && body.confirm !== true) {
920
+ sendJson(response, 400, { error: '应用差异将替换当前版本,请确认后继续' }); return;
921
+ }
922
+ item = { kind: body.kind, name: body.name, apply: action === 'apply' };
923
+ }
924
+ void defaults.sync({ manual: true, item }).then(invalidateStatusCache).catch(() => {});
925
+ sendJson(response, 202, { ok: true, running: true });
926
+ } catch (error) { sendJson(response, 400, { error: error?.message || '默认能力同步失败,请重试' }); }
927
+ });
928
+ }
929
+
1005
930
  register('/agentone/api/model/sync', async (request, response) => {
1006
931
  if (request.method !== 'POST') {
1007
932
  response.writeHead(405, { allow: 'POST' });
@@ -1030,7 +955,7 @@ export function apply(ctx, config) {
1030
955
  try {
1031
956
  const credentials = await ensureFreshToken(config, log);
1032
957
  if (!credentials) {
1033
- sendJson(response, 401, { error: '请先完成飞书登录' });
958
+ sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
1034
959
  return;
1035
960
  }
1036
961
  const result = await syncPlatformSkillhub(config, log, credentials);
@@ -1049,21 +974,16 @@ export function apply(ctx, config) {
1049
974
  try {
1050
975
  const credentials = await ensureFreshToken(config, log);
1051
976
  if (!credentials) {
1052
- sendJson(response, 401, { error: '请先完成飞书登录' });
977
+ sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
1053
978
  return;
1054
979
  }
1055
980
  const body = await readJsonBody(request);
1056
- const slug = String(body.slug || '');
1057
- if (!/^[a-z0-9][a-z0-9-]*(--[a-z0-9][a-z0-9-]*)?$/i.test(slug)) {
1058
- sendJson(response, 400, { error: '技能标识格式不正确' });
1059
- return;
1060
- }
1061
- const result = await installPlatformSkill(
1062
- config.platformUrl,
1063
- credentials.access_token,
1064
- slug,
1065
- { config },
1066
- );
981
+ let slug;
982
+ try { slug = canonicalSkillSlug(body.slug); }
983
+ catch (error) { sendJson(response, 400, { error: error.message }); return; }
984
+ const result = await defaults.manualOperation((owner, options) => installPlatformSkill(
985
+ owner.platform, owner.token, slug, options,
986
+ ));
1067
987
  invalidateStatusCache();
1068
988
  sendJson(response, 200, { ok: true, ...result });
1069
989
  } catch (error) {
@@ -1082,7 +1002,7 @@ export function apply(ctx, config) {
1082
1002
  try {
1083
1003
  const credentials = await ensureFreshToken(config, log);
1084
1004
  if (!credentials) {
1085
- sendJson(response, 401, { error: '请先完成飞书登录' });
1005
+ sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
1086
1006
  return;
1087
1007
  }
1088
1008
  const result = await syncPlanSkills(config, log, credentials);
@@ -1102,7 +1022,7 @@ export function apply(ctx, config) {
1102
1022
  try {
1103
1023
  const credentials = await ensureFreshToken(config, log);
1104
1024
  if (!credentials) {
1105
- sendJson(response, 401, { error: '请先完成飞书登录' });
1025
+ sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
1106
1026
  return;
1107
1027
  }
1108
1028
  const query = new URL(request.url, 'http://localhost').searchParams.get('q') || '';
@@ -1120,7 +1040,15 @@ export function apply(ctx, config) {
1120
1040
  config.platformUrl,
1121
1041
  credentials.access_token,
1122
1042
  ).catch(() => null);
1123
- const installed = (listing?.skills || []).filter((skill) => skill.installed);
1043
+ const identity = defaults.snapshot();
1044
+ const installed = [];
1045
+ if (identity.user_id) {
1046
+ for (const skill of listing?.skills || []) {
1047
+ const local = await localSkillState(config.platformUrl, skill.canonical_slug, {
1048
+ config: { ...config, dshHome: identity.home }, userId: identity.user_id, registry: identity.registry });
1049
+ if (['current', 'report_pending'].includes(local.state)) installed.push({ ...skill, installed: true, installed_version: local.record.version });
1050
+ }
1051
+ }
1124
1052
  sendJson(response, 200, { ...data, installed });
1125
1053
  } catch (error) {
1126
1054
  sendJson(response, 502, { error: '技能市场暂时不可用,请稍后再试' });
@@ -1136,16 +1064,14 @@ export function apply(ctx, config) {
1136
1064
  try {
1137
1065
  const credentials = await loadCredentials(config);
1138
1066
  if (!credentials) {
1139
- sendJson(response, 401, { error: '请先完成飞书登录' });
1067
+ sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
1140
1068
  return;
1141
1069
  }
1142
1070
  const body = await readJsonBody(request);
1143
- const slug = String(body.slug || '');
1144
- if (!/^[a-z0-9][a-z0-9-]*(--[a-z0-9][a-z0-9-]*)?$/i.test(slug)) {
1145
- sendJson(response, 400, { error: '技能标识格式不正确' });
1146
- return;
1147
- }
1148
- await removePlatformSkill(config.platformUrl, credentials.access_token, slug, { config });
1071
+ let slug;
1072
+ try { slug = canonicalSkillSlug(body.slug); }
1073
+ catch (error) { sendJson(response, 400, { error: error.message }); return; }
1074
+ await defaults.manualOperation((owner, options) => removePlatformSkill(owner.platform, owner.token, slug, options));
1149
1075
  invalidateStatusCache();
1150
1076
  sendJson(response, 200, { ok: true, canonical_slug: slug });
1151
1077
  } catch (error) {
@@ -1165,7 +1091,7 @@ export function apply(ctx, config) {
1165
1091
  // 不用 pnpm outdated——它受元数据缓存与 minimumReleaseAge 冷却期
1166
1092
  // 影响,新发布的版本一段时间内查不到,升级按钮追着发版延迟出现。
1167
1093
  // 只查已装且来自 npm 的包;未安装/本地链接没有版本可查。
1168
- const dir = pluginProfileDir(config);
1094
+ const dir = profiles.target().dir;
1169
1095
  await Promise.all(
1170
1096
  state.plugins
1171
1097
  .filter((item) => item.version !== null && item.source === 'npm')
@@ -1240,7 +1166,7 @@ export function apply(ctx, config) {
1240
1166
  // 中版本。升级成功后落盘先变、运行代码要重启宿主才换——两者差值即
1241
1167
  // 前端「已安装待重启」提示的依据。
1242
1168
  try {
1243
- const dir = pluginProfileDir(config);
1169
+ const dir = profiles.target().dir;
1244
1170
  const [version, latest] = await Promise.all([
1245
1171
  installedVersion(dir, PLUGIN_PACKAGE_NAME),
1246
1172
  pnpmViewLatest(PLUGIN_PACKAGE_NAME, dir).catch(() => null),
@@ -1583,7 +1509,7 @@ export function apply(ctx, config) {
1583
1509
  try {
1584
1510
  const credentials = await loadCredentials(config);
1585
1511
  if (!credentials) {
1586
- sendJson(response, 401, { error: '请先完成飞书登录' });
1512
+ sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
1587
1513
  return;
1588
1514
  }
1589
1515
  const plan = await fetchPlatformPlan(config.platformUrl, credentials.access_token);
@@ -1602,7 +1528,7 @@ export function apply(ctx, config) {
1602
1528
  try {
1603
1529
  const credentials = await loadCredentials(config);
1604
1530
  if (!credentials) {
1605
- sendJson(response, 401, { error: '请先完成飞书登录' });
1531
+ sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
1606
1532
  return;
1607
1533
  }
1608
1534
  const body = await readJsonBody(request);
@@ -1641,6 +1567,7 @@ export function apply(ctx, config) {
1641
1567
  try {
1642
1568
  // 先吊销平台侧 refresh token(尽力而为),再清本地;凭据读取失败
1643
1569
  // (文件损坏等)也要走到清凭据,不能把用户卡在登录态。
1570
+ defaults.reset();
1644
1571
  const credentials = await loadCredentials(config);
1645
1572
  if (credentials?.refresh_token) {
1646
1573
  await revokePlatformToken(config.platformUrl, credentials.refresh_token);
@@ -1660,9 +1587,9 @@ export function apply(ctx, config) {
1660
1587
  }
1661
1588
  });
1662
1589
 
1663
- // 启动时若已有登录态:先保鲜令牌(临期轮换),再后台刷新模型配置、
1664
- // SkillHub 配置与套餐技能。内置插件常驻检测与登录无关,独立先行。
1665
- ensureBuiltinPlugins(config, log).catch((error) => {
1590
+ // 默认能力属于已登录平台;无凭据不安装兼容默认。协调器独立于页面存活。
1591
+ // 内置插件常驻检测与登录无关,独立先行。
1592
+ ensureBuiltinPlugins().catch((error) => {
1666
1593
  log.warn(`[dsh-agentone] builtin plugin check failed: ${error?.message || error}`);
1667
1594
  });
1668
1595
  ensureLanguageDefault(config, log).catch((error) => {
@@ -1674,17 +1601,20 @@ export function apply(ctx, config) {
1674
1601
  log.warn(`[dsh-agentone] instance credential bootstrap failed: ${error?.message || error}`);
1675
1602
  });
1676
1603
  const credentials = await ensureFreshToken(config, log);
1677
- if (!credentials) return;
1604
+ if (!credentials || disposed) return;
1605
+ void defaults.sync().then(invalidateStatusCache).catch(() => {});
1678
1606
  await syncPlatformModel(config, log).catch(() => {});
1679
1607
  // SkillHub 连接在托管容器里由 Hub 直接注入 env(CLAWHUB_* / SKILLHUB_*),
1680
1608
  // 插件不再重复写 skillhub.env / clawhub login,避免两个写入方互相盖。
1681
1609
  if (!isInstanceManaged()) {
1682
1610
  await syncPlatformSkillhub(config, log, credentials).catch(() => {});
1683
1611
  }
1684
- await syncPlanSkills(config, log, credentials).catch(() => {});
1685
1612
  })();
1686
1613
 
1687
- return () => {
1614
+ return async () => {
1615
+ disposed = true;
1616
+ await defaults.dispose();
1617
+ await profiles.dispose();
1688
1618
  pendingStates.clear();
1689
1619
  pendingCliAuth.clear();
1690
1620
  clearModelRetry();