dsh-agentone 0.5.16 → 0.5.18

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/README.md CHANGED
@@ -14,12 +14,24 @@ lark-cli / ccpg-cli 状态探测。
14
14
  走 TokenHub 按用户计量。平台 403 的 `plan_required` 错误码原样透传,
15
15
  前端据此引导申请套餐(不依赖中文文案匹配)。
16
16
  - **SkillHub 技能(M3)**:套餐技能启动/登录时自动装齐;技能市场搜索、
17
- 手动安装/卸载;技能包 zip 走平台代理下载(穿越清洗 + 原子落盘)。
17
+ 手动安装/卸载;已装技能平台侧对比 latest 版本/指纹,有更新时展示
18
+ 「v旧 → v新」并支持一键升级(覆盖式重装,原子替换旧目录);技能包
19
+ zip 走平台代理下载(穿越清洗 + 原子落盘)。
20
+ - **SkillHub 连接配置(M3+)**:登录后自动从平台拉取 registry 与用户级
21
+ 个人 token,写入 `$DSH_HOME/agentone/skillhub.env`(0600,含
22
+ `SKILLHUB_BASE_URL`/`SKILLHUB_TOKEN`/`CLAWHUB_REGISTRY`/`CLAWHUB_TOKEN`
23
+ 四个与平台托管容器同名的变量),本机有 clawhub CLI 时同步写入登录态;
24
+ 登出时全部清理。会话内使用:`source $DSH_HOME/agentone/skillhub.env`。
18
25
  - **套餐申请(M3+)**:状态页「套餐」tab 展示当前套餐/审批中/可申请列表,
19
26
  一键提交申请;审批通过后前端轮询检测到 active 自动同步模型。
20
27
  - **dsh 插件管理(M3+)**:列出镜像内置与用户自装插件(版本/升级/激活态),
21
- 内置插件缺失时启动自动重装;自身随平台镜像升级(列表中不提供按钮)。
22
- - **CLI 探测(M4)**:`lark-cli` / `ccpg-cli` 安装与授权态三态探测。
28
+ 内置插件缺失时启动自动重装(自身也在常驻保证清单内,防误删导致平台集成
29
+ 失效);desktop 受管 pnpm 通道操作串行排队,宿主并发锁占用时自动退避
30
+ 重试(不把「another desktop pnpm operation is already running」抛给用户);
31
+ 自身随平台镜像升级(列表中不提供按钮)。
32
+ - **CLI 探测与授权(M4)**:`lark-cli` / `ccpg-cli` 安装与授权态三态探测;
33
+ 已装未授权时「去授权」发起官方 Device Flow(授权链接 + ASCII 二维码 +
34
+ 验证码 + 自动轮询确认,设备码仅存插件进程内存)。
23
35
  - **首启引导**:dsh 首次启动弹出飞书登录引导(order -50,早于官方
24
36
  「添加 API Key」),登录一次即跳过官方密钥引导;暗色主题适配。
25
37
 
@@ -49,6 +61,9 @@ dsh plugin --profile web add dsh-agentone
49
61
  - `$DSH_HOME/.credentials.yaml` 写入 `refs.AGENTONE_API_KEY`(0600),内容
50
62
  是 30 天插件令牌而非真实模型密钥——密钥不出平台,TokenHub 按用户计量;
51
63
  - 只覆盖 `platform` 命名空间,用户自己配置的其它 provider 原样保留;
64
+ - 同步失败(如平台侧 AirRouter 瞬时不可达)不阻断登录/启动,原因记录在
65
+ 状态页「同步状态」;状态页轮询发现残留错误时按指数退避(1min 起、
66
+ 封顶 30min、最多 30 次)自动重试,平台恢复后无需重启 dsh 即自愈;
52
67
  - 「退出登录」(页头按钮)会吊销平台令牌、移除凭据引用与 platform
53
68
  provider(其它 provider 的默认模型设置不受影响);凭据文件损坏时也能
54
69
  正常退出(坏文件移开为 `credentials.json.corrupt`)。
@@ -89,7 +104,7 @@ lib/
89
104
  skill.js 技能下载/解包/卸载(zip 穿越清洗、原子落盘)
90
105
  store.js 凭据持久化(0600;损坏自动移开)
91
106
  proc.js dsh/pnpm CLI 转发与 lark-cli/ccpg-cli 探测
92
- client.js dsh 浏览器端 bundle(设置菜单 iframe + 首启引导)
107
+ client.js dsh 浏览器端 bundle(设置菜单 iframe + 首启引导 + 设置导航 workbench 图标换肤)
93
108
  ```
94
109
 
95
110
  ## 开发
@@ -0,0 +1,115 @@
1
+ // CLI Device Flow 授权(与平台 cli-deployd auth_begin/auth_complete 同源语义)。
2
+ //
3
+ // 独立 dsh 的 CLI 授权由插件进程直接驱动本机 CLI(lark-cli / ccpg-cli 官方
4
+ // 设备流命令,两 CLI 参数面一致):
5
+ // beginCliAuth 发起授权(--no-wait --json),解析出 verification_url /
6
+ // user_code / 设备码 / expires_in;ccpg-cli 老版本不支持
7
+ // --json 时去掉重试(文本解析兜底)。
8
+ // completeCliAuth 用 begin 拿到的设备码轮询并完成授权(阻塞至用户在
9
+ // 授权页同意或超时)。
10
+ // cliAuthQrcode lark-cli 的 auth qrcode --ascii 生成终端二维码(状态页
11
+ // 等宽字体直接渲染)。
12
+ //
13
+ // 安全:所有子进程经 runCliTool(execFile 参数数组,无 shell 参与)。设备码
14
+ // 是 begin 步骤本机 CLI 自己签发的随机 token,非用户自由输入;仍先过
15
+ // sanitizeDeviceCode 白名单(字母数字、连字符与点号,无空白与其它符号),校验
16
+ // 失败直接抛中文错误、不触碰子进程。校验后的 token 以独立 argv 元素传给
17
+ // execFile——无 shell 二次解析,token 字符集也决定了它不可能被解释为选项。
18
+ import { runCliTool } from './proc.js';
19
+
20
+ /** 授权 begin/complete 超时对齐平台 cli-deployd(begin 30s / complete 240s)。 */
21
+ export const CLI_AUTH_BEGIN_TIMEOUT_MS = 30_000;
22
+ export const CLI_AUTH_COMPLETE_TIMEOUT_MS = 240_000;
23
+
24
+ /** 设备码白名单:官方设备码为字母数字、连字符与点号(实测 lark-cli 的
25
+ * device_code 含 base64url 段 + '.' 分隔的签名后缀),长度 4–256。 */
26
+ const DEVICE_CODE_RE = /^[A-Za-z0-9_.-]{4,256}$/;
27
+
28
+ /** 校验设备码:非法值返回 null(调用方直接拒绝,不进任何子进程参数)。 */
29
+ export function sanitizeDeviceCode(raw) {
30
+ const value = String(raw || '').trim();
31
+ return DEVICE_CODE_RE.test(value) ? value : null;
32
+ }
33
+
34
+ function cliToolEnv(tool) {
35
+ return tool === 'ccpg-cli' ? { CCPG_FULL_CLI: '1' } : undefined;
36
+ }
37
+
38
+ function combineOutput(stdout, stderr) {
39
+ return `${stdout || ''}\n${stderr || ''}`.trim();
40
+ }
41
+
42
+ /** 发起 Device Flow:auth login --no-wait,返回原始输出(含 JSON/URL)。 */
43
+ export async function beginCliAuth(tool, { timeoutMs = CLI_AUTH_BEGIN_TIMEOUT_MS } = {}) {
44
+ const options = { timeoutMs, env: cliToolEnv(tool) };
45
+ let result;
46
+ try {
47
+ result = await runCliTool(tool, ['auth', 'login', '--no-wait', '--json'], options);
48
+ } catch (error) {
49
+ if (tool !== 'ccpg-cli') throw error;
50
+ result = await runCliTool(tool, ['auth', 'login', '--no-wait'], options);
51
+ }
52
+ return combineOutput(result.stdout, result.stderr);
53
+ }
54
+
55
+ /**
56
+ * 完成 Device Flow:用 begin 返回的设备码轮询并完成授权(阻塞至用户同意
57
+ * 或超时)。设备码先过白名单(见文件头);失败抛中文错误。
58
+ */
59
+ export async function completeCliAuth(tool, rawDeviceCode, { timeoutMs = CLI_AUTH_COMPLETE_TIMEOUT_MS } = {}) {
60
+ const token = sanitizeDeviceCode(rawDeviceCode);
61
+ if (token === null) {
62
+ throw new Error('设备授权码格式不正确');
63
+ }
64
+ const args = ['auth', 'login', '--device-code', token];
65
+ const { stdout, stderr } = await runCliTool(tool, args, {
66
+ timeoutMs,
67
+ env: cliToolEnv(tool),
68
+ });
69
+ return combineOutput(stdout, stderr);
70
+ }
71
+
72
+ /** lark-cli 生成授权 URL 的 ASCII 二维码(状态页等宽字体直接渲染)。 */
73
+ export async function cliAuthQrcode(url, { timeoutMs = 15_000 } = {}) {
74
+ const { stdout } = await runCliTool('lark-cli', ['auth', 'qrcode', String(url || ''), '--ascii'], {
75
+ timeoutMs,
76
+ });
77
+ return String(stdout || '').trim();
78
+ }
79
+
80
+ /**
81
+ * 从 auth begin 输出解析授权信息(verification_url / user_code / 设备码)。
82
+ * 与平台 _normalize_cli_auth_data 同口径:JSON 优先(lark-cli --json),
83
+ * 键名兼容 snake/camel;ccpg-cli 纯文本输出退化为正则抽 URL/code。
84
+ */
85
+ export function parseCliAuthBeginOutput(raw) {
86
+ const text = String(raw || '');
87
+ const jsonMatcher = /\{[^{}]*\}/g;
88
+ for (const chunk of text.match(jsonMatcher) || []) {
89
+ try {
90
+ const data = JSON.parse(chunk);
91
+ if (!data || typeof data !== 'object') continue;
92
+ const verificationUrl = data.verification_url || data.verificationUrl || data.verification_uri || data.url;
93
+ const userCode = data.user_code || data.userCode || data.code || '';
94
+ const deviceCode = data.device_code || data.deviceCode || '';
95
+ const expiresIn = Number(data.expires_in ?? data.expiresIn) || 600;
96
+ if (verificationUrl && (deviceCode || userCode)) {
97
+ return {
98
+ verificationUrl: String(verificationUrl),
99
+ userCode: String(userCode),
100
+ deviceCode: String(deviceCode),
101
+ expiresIn,
102
+ };
103
+ }
104
+ } catch {
105
+ // 非 JSON 块继续
106
+ }
107
+ }
108
+ // ccpg-cli 文本形态:URL 与 code 各取首个出现
109
+ const urlMatch = /https?:\/\/[^\s"'<>]+/.exec(text);
110
+ if (!urlMatch) return null;
111
+ const withoutUrl = text.slice(0, urlMatch.index) + ' ' + text.slice(urlMatch.index + urlMatch[0].length);
112
+ const codeMatch = /\b([A-Z0-9]{4,12})\b/.exec(withoutUrl);
113
+ if (!codeMatch) return null;
114
+ return { verificationUrl: urlMatch[0], userCode: codeMatch[1], deviceCode: codeMatch[1], expiresIn: 600 };
115
+ }
package/lib/client.js CHANGED
@@ -189,7 +189,58 @@ window.__ModuleLoader__.load({
189
189
  );
190
190
  }
191
191
 
192
+ // ── 设置导航图标(workbench_fill)─────────────────────────────────────
193
+ // dsh 设置弹窗的导航图标由 shell(ui-settings-general)按 section id 硬编码,
194
+ // 注册项没有 icon 选项(仅 models/agent-presets/plugins 有专用图标,其余一律
195
+ // fallback 齿轮)。改用「标记 + CSS」换肤:给「AgentOne」导航格打 data 属性,
196
+ // 注入样式隐藏官方 svg、用 mask 画 workbench 图标;颜色取 currentColor(导航格
197
+ // color 即 --dsw-alias-label-primary),随 dsh 明暗主题自动适配。只加属性不
198
+ // 改节点结构,React 重渲染/卸载都不会被破坏。
199
+ const NAV_ICON_LABEL = 'AgentOne'; // 与 settings.section 注册的 label 保持一致
200
+ const NAV_ICON_PATH = 'M160 800.256C160 835.424 188.608 864 223.712 864h92.48V448H160v352.256zM380.16 864h420.096A63.808 63.808 0 0 0 864 800.256V448H380.16v416zM800.256 160H223.68A63.808 63.808 0 0 0 160 223.744V384h704V223.744A63.776 63.776 0 0 0 800.256 160';
201
+
202
+ function tagNavIconCells() {
203
+ for (const cell of document.querySelectorAll('button')) {
204
+ if (cell.textContent.trim() !== NAV_ICON_LABEL) continue;
205
+ if (cell.querySelector('svg')) cell.setAttribute('data-agentone-nav-cell', '');
206
+ }
207
+ }
208
+
209
+ function injectNavIconStyle() {
210
+ if (document.getElementById('agentone-nav-icon-style')) return;
211
+ const icon = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='${NAV_ICON_PATH}'/%3E%3C/svg%3E`;
212
+ const style = document.createElement('style');
213
+ style.id = 'agentone-nav-icon-style';
214
+ style.textContent = [
215
+ 'button[data-agentone-nav-cell] > svg { display: none; }',
216
+ 'button[data-agentone-nav-cell]::before {',
217
+ " content: ''; flex: none; width: 16px; height: 16px;",
218
+ ' background-color: currentColor;',
219
+ ` -webkit-mask: url("${icon}") center / contain no-repeat;`,
220
+ ` mask: url("${icon}") center / contain no-repeat;`,
221
+ '}',
222
+ ].join('\n');
223
+ document.head.appendChild(style);
224
+ }
225
+
226
+ // 设置面板每次打开都重新挂载导航节点:监听 DOM 变化补打标记
227
+ // (rAF 节流,且只在有新增节点时才扫描)
228
+ function setupNavIcon() {
229
+ injectNavIconStyle();
230
+ let scheduled = false;
231
+ new MutationObserver((records) => {
232
+ if (scheduled || !records.some((record) => record.addedNodes.length)) return;
233
+ scheduled = true;
234
+ requestAnimationFrame(() => {
235
+ scheduled = false;
236
+ tagNavIconCells();
237
+ });
238
+ }).observe(document.body, { childList: true, subtree: true });
239
+ tagNavIconCells();
240
+ }
241
+
192
242
  function apply(ctx) {
243
+ setupNavIcon();
193
244
  ctx.slots.inject('settings.section', () =>
194
245
  ctx.slots.register(
195
246
  {
package/lib/index.js CHANGED
@@ -30,6 +30,8 @@ import {
30
30
  } from './auth.js';
31
31
  import { applyPlatformModel, removePlatformModel } from './model.js';
32
32
  import { installPlatformSkill, listPlatformSkills, removePlatformSkill } from './skill.js';
33
+ import { applyPlatformSkillhub, removePlatformSkillhub } from './skillhub.js';
34
+ import { beginCliAuth, cliAuthQrcode, completeCliAuth, parseCliAuthBeginOutput } from './cliauth.mjs';
33
35
  import { platformFetch } from './http.js';
34
36
  import { donePageHtml, statusPageHtml } from './page.js';
35
37
  import { compareSemver, dshPluginCli, firstErrorLines, probeCliDetail, pnpmViewLatest, upgradeCliTool } from './proc.js';
@@ -105,6 +107,33 @@ async function syncPlatformModel(config, log) {
105
107
  }
106
108
  }
107
109
 
110
+ /** 应用平台 SkillHub 配置并落盘(成功清 skillhub_error,失败记录原因)。
111
+ * 与 syncPlatformModel 同容错模式:失败不阻断登录/启动,状态页展示原因。 */
112
+ async function syncPlatformSkillhub(config, log, credentials) {
113
+ const accessToken = credentials?.access_token;
114
+ if (!accessToken) return null;
115
+ try {
116
+ const result = await applyPlatformSkillhub(
117
+ config,
118
+ credentials.platform_url || config.platformUrl,
119
+ accessToken,
120
+ );
121
+ await updateCredentials(config, {
122
+ skillhub_registry: result.registryUrl,
123
+ skillhub_token: result.token,
124
+ skillhub_synced_at: new Date().toISOString(),
125
+ skillhub_error: null,
126
+ });
127
+ log.info(`[dsh-agentone] platform skillhub config applied: ${result.registryUrl}`);
128
+ return result;
129
+ } catch (error) {
130
+ const message = error?.message || String(error);
131
+ await updateCredentials(config, { skillhub_error: message }).catch(() => {});
132
+ log.warn(`[dsh-agentone] platform skillhub config sync failed: ${message}`);
133
+ throw error;
134
+ }
135
+ }
136
+
108
137
  /** 进行中的令牌轮换:临期窗口内多个请求共享同一次 refresh(轮换是
109
138
  * 一次性的,并发各刷一次会 401 把用户登出)。 */
110
139
  let tokenRefreshInFlight = null;
@@ -165,6 +194,41 @@ function pluginProfileDir(config) {
165
194
  return join(resolveDshHome(config), 'profiles', PLUGIN_PROFILE);
166
195
  }
167
196
 
197
+ /** 模型同步失败自愈重试调度(模块级单例:apply 重入先清旧定时器)。
198
+ * 平台侧瞬时故障(如 AirRouter 短暂不可达)会让启动同步失败,错误一直
199
+ * 挂在状态页直到下次 dsh 重启——这里由 5s 状态轮询驱动:发现 model_error
200
+ * 就按指数退避后台重试,成功即清错误,无需用户干预。 */
201
+ export const MODEL_RETRY_BASE_MS = 60_000;
202
+ export const MODEL_RETRY_MAX_MS = 30 * 60_000;
203
+ export const MODEL_RETRY_MAX_ATTEMPTS = 30;
204
+ let modelRetryTimer = null;
205
+ let modelRetryAttempts = 0;
206
+
207
+ function clearModelRetry() {
208
+ if (modelRetryTimer !== null) {
209
+ clearTimeout(modelRetryTimer);
210
+ modelRetryTimer = null;
211
+ }
212
+ }
213
+
214
+ function scheduleModelRetry(config, log) {
215
+ if (modelRetryTimer !== null || modelRetryAttempts >= MODEL_RETRY_MAX_ATTEMPTS) return;
216
+ const delay = Math.min(MODEL_RETRY_BASE_MS * 2 ** modelRetryAttempts, MODEL_RETRY_MAX_MS);
217
+ modelRetryAttempts += 1;
218
+ modelRetryTimer = setTimeout(() => {
219
+ modelRetryTimer = null;
220
+ log.info(`[dsh-agentone] model sync self-heal retry #${modelRetryAttempts}`);
221
+ syncPlatformModel(config, log)
222
+ .then(() => {
223
+ modelRetryAttempts = 0;
224
+ })
225
+ .catch(() => {
226
+ scheduleModelRetry(config, log);
227
+ });
228
+ }, delay);
229
+ modelRetryTimer.unref?.();
230
+ }
231
+
168
232
  /** 转发 dsh plugin 子命令:desktop 走受管 pnpm,普通 dsh 走官方 CLI。 */
169
233
  async function runPluginCli(args) {
170
234
  if (desktopPluginCli) {
@@ -173,6 +237,12 @@ async function runPluginCli(args) {
173
237
  return dshPluginCli(args, PLUGIN_PROFILE);
174
238
  }
175
239
 
240
+ /** 宿主 DesktopPnpmService 的并发锁占用报错(同一时刻只允许一个 pnpm
241
+ * 操作——设计如此,不是故障)。识别后应排队等待而非把错误抛给用户。 */
242
+ const DESKTOP_PNPM_BUSY_RE = /another desktop pnpm operation is already running/i;
243
+
244
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
245
+
176
246
  /**
177
247
  * 包装宿主 desktopPnpm.runPlugin,对齐 dshPluginCli 的返回契约(成功
178
248
  * {stdout, stderr},失败 throw Error)。宿主契约(dsh-plugin-desktop 的
@@ -183,36 +253,69 @@ async function runPluginCli(args) {
183
253
  * 2. 返回 {stdout, stderr, done, cancel} 句柄而非 Promise,done 结算
184
254
  * {exitCode, signal} 且不因非零退出 reject——直接 await 句柄会立即
185
255
  * 返回,装完前的落盘校验必然读到旧版本,误报「升级未生效」。
256
+ *
257
+ * 并发占用(启动时常驻检测 vs 用户在插件市场装包同时发生)是高频场景:
258
+ * 宿主 busy 时同步抛错。这里做两层防御——
259
+ * 1. 插件自身操作串行队列:自己绝不和自己抢锁;
260
+ * 2. 对宿主 busy 退避重试(2s 起最多 5 次约 2 分钟):等宿主侧的其它
261
+ * 操作(插件市场安装等)结束后继续,而不是报错打断用户。
186
262
  */
187
263
  export function desktopRunPlugin(service, invokingDir) {
188
- return async (args) => {
189
- const handle = service.runPlugin([...args], invokingDir);
190
- let stdout = '';
191
- let stderr = '';
192
- const append = (acc, chunk) => (acc + String(chunk)).slice(-64 * 1024);
193
- handle.stdout?.on?.('data', (chunk) => { stdout = append(stdout, chunk); });
194
- handle.stderr?.on?.('data', (chunk) => { stderr = append(stderr, chunk); });
195
- // 与 web 通道 runCliTool 同宽的超时(300s):宿主自身不设整体超时
196
- let timedOut = false;
197
- const timer = setTimeout(() => {
198
- timedOut = true;
199
- handle.cancel();
200
- }, 300_000);
201
- try {
202
- const outcome = await handle.done;
203
- if (timedOut) throw new Error('命令执行超时,请稍后重试');
204
- if (outcome.exitCode !== 0 || outcome.signal != null) {
205
- // ERR_PNPM_IGNORED_BUILDS 等非零退出在这里抛出,由调用侧
206
- // install/upgrade 的「以落盘版本为准」兜底接管
207
- const message = firstErrorLines(`${stderr}\n${stdout}`)
208
- || `命令执行失败(退出码 ${outcome.exitCode ?? '未知'})`;
209
- throw new Error(message);
264
+ let queue = Promise.resolve();
265
+ const run = async (args) => {
266
+ // 宿主 lock 是同步抛错(runPlugin 调用即抛),先探再跑 + 失败退避都做
267
+ let attempt = 0;
268
+ for (;;) {
269
+ let handle;
270
+ try {
271
+ handle = service.runPlugin([...args], invokingDir);
272
+ } catch (error) {
273
+ const message = error?.message || String(error);
274
+ if (DESKTOP_PNPM_BUSY_RE.test(message) && attempt < 5) {
275
+ attempt += 1;
276
+ await sleep(2000 * attempt);
277
+ continue;
278
+ }
279
+ throw error;
280
+ }
281
+ let stdout = '';
282
+ let stderr = '';
283
+ const append = (acc, chunk) => (acc + String(chunk)).slice(-64 * 1024);
284
+ handle.stdout?.on?.('data', (chunk) => { stdout = append(stdout, chunk); });
285
+ handle.stderr?.on?.('data', (chunk) => { stderr = append(stderr, chunk); });
286
+ // 与 web 通道 runCliTool 同宽的超时(300s):宿主自身不设整体超时
287
+ let timedOut = false;
288
+ const timer = setTimeout(() => {
289
+ timedOut = true;
290
+ handle.cancel();
291
+ }, 300_000);
292
+ try {
293
+ const outcome = await handle.done;
294
+ if (timedOut) throw new Error('命令执行超时,请稍后重试');
295
+ if (outcome.exitCode !== 0 || outcome.signal != null) {
296
+ const message = firstErrorLines(`${stderr}\n${stdout}`);
297
+ // 旧版宿主在子进程段抛 busy:同样排队重试
298
+ if (DESKTOP_PNPM_BUSY_RE.test(message) && attempt < 5) {
299
+ attempt += 1;
300
+ await sleep(2000 * attempt);
301
+ continue;
302
+ }
303
+ // ERR_PNPM_IGNORED_BUILDS 等非零退出在这里抛出,由调用侧
304
+ // install/upgrade 的「以落盘版本为准」兜底接管
305
+ throw new Error(message || `命令执行失败(退出码 ${outcome.exitCode ?? '未知'})`);
306
+ }
307
+ return { stdout, stderr };
308
+ } finally {
309
+ clearTimeout(timer);
210
310
  }
211
- return { stdout, stderr };
212
- } finally {
213
- clearTimeout(timer);
214
311
  }
215
312
  };
313
+ return (args) => {
314
+ const next = queue.then(() => run(args));
315
+ // 队列尾吞错,避免一次失败卡死后续排队操作
316
+ queue = next.catch(() => undefined);
317
+ return next;
318
+ };
216
319
  }
217
320
 
218
321
  /**
@@ -289,7 +392,17 @@ const INBOX_BUNDLES = new Set([
289
392
  * 每次 dsh 启动时检测:缺失则静默重装(常驻保证),已装则不碰(尊重用户
290
393
  * 的版本选择)。
291
394
  */
292
- const BUILTIN_PLUGINS = ['dsh-better-sidebar', 'dsh-harness-one', 'dshmarket', 'dsh-im'];
395
+ const BUILTIN_PLUGINS = [
396
+ 'dsh-better-sidebar',
397
+ 'dsh-harness-one',
398
+ 'dshmarket',
399
+ 'dsh-im',
400
+ // 自身也纳入常驻保证:独立/桌面版用户删掉依赖或 profile 损坏导致本插件
401
+ // 缺失时,最后一个运行周期内无法自救(插件代码不在了)——但只要还装着,
402
+ // 卸载残留/版本损坏后的重装走这里。镜像内 self_managed 时列表页隐藏
403
+ // 卸载按钮,不会出现「自动重装刚被用户卸载的自己」的拉扯。
404
+ PLUGIN_PACKAGE_NAME,
405
+ ];
293
406
 
294
407
  /**
295
408
  * 内置插件的用途说明(以各自官方 README 为准):用户视角讲清楚装了能
@@ -304,6 +417,8 @@ const BUILTIN_PLUGIN_DESCRIPTIONS = {
304
417
  '插件市场:在 dsh 设置里逛社区插件,像应用商店一样点一下就装好',
305
418
  'dsh-im':
306
419
  'IM 桥核心:让 dsh 接入飞书等聊天工具——群内 @机器人 干活、收通知与审批卡片',
420
+ [PLUGIN_PACKAGE_NAME]:
421
+ 'AgentOne 平台集成:飞书登录、平台模型、SkillHub 技能与 CLI 授权(本插件)',
307
422
  };
308
423
 
309
424
  /** 汇总 profile 插件状态:dependencies(去掉 in-box)+ 已装版本 + 激活态。 */
@@ -368,6 +483,7 @@ const BUILTIN_PLUGIN_ALLOW_BUILD = {
368
483
  'dsh-harness-one': ['--allow-build=node-pty'],
369
484
  dshmarket: ['--allow-build=node-pty'],
370
485
  'dsh-im': [],
486
+ [PLUGIN_PACKAGE_NAME]: [],
371
487
  };
372
488
 
373
489
  /** 安装:白名单校验后转发(desktop 受管 pnpm / 官方 CLI;参数数组,无 shell)。
@@ -589,6 +705,10 @@ button{font:inherit;padding:9px 22px;border-radius:10px;border:none;cursor:point
589
705
  });
590
706
  log.info('[dsh-agentone] platform login stored (desktop callback server)');
591
707
  await syncPlatformModel(config, log).catch(() => {});
708
+ await syncPlatformSkillhub(config, log, {
709
+ ...(await loadCredentials(config)),
710
+ access_token: result.access_token,
711
+ }).catch(() => {});
592
712
  await syncPlanSkills(config, log, {
593
713
  ...(await loadCredentials(config)),
594
714
  access_token: result.access_token,
@@ -633,6 +753,9 @@ export function apply(ctx, config) {
633
753
  });
634
754
  }
635
755
  ctx.effect(() => {
756
+ // 模块级重试定时器跨 apply 存活:重入先清,避免旧定时器引用旧 config
757
+ clearModelRetry();
758
+ modelRetryAttempts = 0;
636
759
  const register = (path, handler) => ctx.webServer.register({ kind: 'exact', path, handler });
637
760
 
638
761
  register('/agentone/', (request, response) => {
@@ -675,6 +798,14 @@ export function apply(ctx, config) {
675
798
  if (userEmail) {
676
799
  ({ capabilities, skills, displayNames } = await cachedPlatformStatus(config, credentials));
677
800
  }
801
+ // 失败自愈:模型同步残留错误时由轮询驱动后台重试(成功即清,
802
+ // 下次轮询状态页恢复);错误已被登录/手动同步清除时取消待定
803
+ // 重试并复位退避计数。
804
+ if (credentials.model_error) scheduleModelRetry(config, log);
805
+ else {
806
+ clearModelRetry();
807
+ modelRetryAttempts = 0;
808
+ }
678
809
  }
679
810
  sendJson(response, 200, {
680
811
  platform_url: config.platformUrl,
@@ -696,6 +827,13 @@ export function apply(ctx, config) {
696
827
  error_code: credentials.model_error_code ?? null,
697
828
  }
698
829
  : null,
830
+ skillhub: credentials
831
+ ? {
832
+ registry: credentials.skillhub_registry ?? null,
833
+ synced_at: credentials.skillhub_synced_at ?? null,
834
+ error: credentials.skillhub_error ?? null,
835
+ }
836
+ : null,
699
837
  });
700
838
  } catch (error) {
701
839
  log.warn(`[dsh-agentone] status failed: ${error?.message || error}`);
@@ -768,8 +906,12 @@ export function apply(ctx, config) {
768
906
  expires_at: expiresIn ? new Date(Date.now() + expiresIn * 1000).toISOString() : null,
769
907
  });
770
908
  log.info('[dsh-agentone] platform login stored');
771
- // 登录即配置模型 + 同步套餐技能:失败不阻断登录,状态页展示原因
909
+ // 登录即配置模型 + SkillHub + 同步套餐技能:失败不阻断登录,状态页展示原因
772
910
  await syncPlatformModel(config, log).catch(() => {});
911
+ await syncPlatformSkillhub(config, log, {
912
+ ...(await loadCredentials(config)),
913
+ access_token: result.access_token,
914
+ }).catch(() => {});
773
915
  await syncPlanSkills(config, log, {
774
916
  ...(await loadCredentials(config)),
775
917
  access_token: result.access_token,
@@ -799,6 +941,25 @@ export function apply(ctx, config) {
799
941
  }
800
942
  });
801
943
 
944
+ register('/agentone/api/skillhub/sync', async (request, response) => {
945
+ if (request.method !== 'POST') {
946
+ response.writeHead(405, { allow: 'POST' });
947
+ response.end();
948
+ return;
949
+ }
950
+ try {
951
+ const credentials = await ensureFreshToken(config, log);
952
+ if (!credentials) {
953
+ sendJson(response, 401, { error: '请先完成飞书登录' });
954
+ return;
955
+ }
956
+ const result = await syncPlatformSkillhub(config, log, credentials);
957
+ sendJson(response, 200, { ok: true, registry: result.registryUrl });
958
+ } catch (error) {
959
+ sendJson(response, 502, { error: error?.message || '同步 SkillHub 配置失败' });
960
+ }
961
+ });
962
+
802
963
  register('/agentone/api/skill/install', async (request, response) => {
803
964
  if (request.method !== 'POST') {
804
965
  response.writeHead(405, { allow: 'POST' });
@@ -1031,6 +1192,123 @@ export function apply(ctx, config) {
1031
1192
  }
1032
1193
  });
1033
1194
 
1195
+ // 进行中的 Device Flow:deviceCode 只存内存(进程级),complete 用后即删。
1196
+ // 与 pendingStates 同生命周期语义——插件重启后用户重新发起即可。
1197
+ const pendingCliAuth = new Map();
1198
+
1199
+ register('/agentone/api/cli/auth/begin', async (request, response) => {
1200
+ if (request.method !== 'POST') {
1201
+ response.writeHead(405, { allow: 'POST' });
1202
+ response.end();
1203
+ return;
1204
+ }
1205
+ try {
1206
+ const body = await readJsonBody(request);
1207
+ const tool = String(body.tool || '');
1208
+ if (!CLI_TOOLS.has(tool)) {
1209
+ sendJson(response, 400, { error: '不支持的 CLI 工具' });
1210
+ return;
1211
+ }
1212
+ const detail = await probeCliDetail(tool).catch(() => null);
1213
+ if (detail?.state === 'missing') {
1214
+ sendJson(response, 400, { error: 'CLI 未安装,请先安装或联系管理员' });
1215
+ return;
1216
+ }
1217
+ const raw = await beginCliAuth(tool);
1218
+ const parsed = parseCliAuthBeginOutput(raw);
1219
+ if (!parsed?.verificationUrl || !parsed?.deviceCode) {
1220
+ sendJson(response, 502, { error: '发起授权失败,请稍后重试' });
1221
+ return;
1222
+ }
1223
+ pendingCliAuth.set(tool, parsed.deviceCode);
1224
+ sendJson(response, 200, {
1225
+ ok: true,
1226
+ tool,
1227
+ verification_url: parsed.verificationUrl,
1228
+ user_code: parsed.userCode,
1229
+ expires_in: parsed.expiresIn,
1230
+ });
1231
+ } catch (error) {
1232
+ sendJson(response, 502, { error: error?.message || '发起授权失败,请稍后重试' });
1233
+ }
1234
+ });
1235
+
1236
+ register('/agentone/api/cli/auth/complete', async (request, response) => {
1237
+ if (request.method !== 'POST') {
1238
+ response.writeHead(405, { allow: 'POST' });
1239
+ response.end();
1240
+ return;
1241
+ }
1242
+ try {
1243
+ const body = await readJsonBody(request);
1244
+ const tool = String(body.tool || '');
1245
+ if (!CLI_TOOLS.has(tool)) {
1246
+ sendJson(response, 400, { error: '不支持的 CLI 工具' });
1247
+ return;
1248
+ }
1249
+ // 只信 begin 存在本进程内存里的设备码,不接收前端回传——前端回环
1250
+ // 会让任意页面伪造 complete 调用。
1251
+ const deviceCode = pendingCliAuth.get(tool);
1252
+ if (!deviceCode) {
1253
+ sendJson(response, 400, { error: '授权会话不存在或已过期,请重新发起授权' });
1254
+ return;
1255
+ }
1256
+ try {
1257
+ // 单次 complete 限时 25s:complete 是真长轮询(CLI 阻塞到用户同意
1258
+ // 或设备码过期),若不限时前端每轮询一次就叠一个子进程,几十个
1259
+ // lark-cli 同时轮同一码。这里 25s 一掐,前端串行重试(见 page.js)。
1260
+ await completeCliAuth(tool, deviceCode, { timeoutMs: 25_000 });
1261
+ pendingCliAuth.delete(tool);
1262
+ const detail = await probeCliDetail(tool).catch(() => null);
1263
+ sendJson(response, 200, { ok: true, tool: detail });
1264
+ } catch (error) {
1265
+ // 超时=我们主动掐断轮询(用户还没在授权页同意):保留会话,202
1266
+ // 让前端继续等;只有终态(设备码失效/被拒)才删会话,否则用户
1267
+ // 慢一点授权就会因会话被删而 400「重新发起」。
1268
+ const message = String(error?.message || '');
1269
+ const terminal = /expired|invalid|denied|拒绝|失效|过期/i.test(message);
1270
+ if (terminal) pendingCliAuth.delete(tool);
1271
+ sendJson(response, terminal ? 502 : 202, {
1272
+ pending: !terminal,
1273
+ error: message,
1274
+ });
1275
+ }
1276
+ } catch (error) {
1277
+ sendJson(response, 502, { error: error?.message || '授权确认失败,请稍后重试' });
1278
+ }
1279
+ });
1280
+
1281
+ register('/agentone/api/cli/auth/qrcode', async (request, response) => {
1282
+ if (request.method !== 'GET') {
1283
+ response.writeHead(405, { allow: 'GET' });
1284
+ response.end();
1285
+ return;
1286
+ }
1287
+ // 只为 begin 返回过的 URL 生成二维码:URL 来自本机 CLI 输出,但也不
1288
+ // 直接接受前端任意 URL(避免把本接口当任意二维码渲染器)。
1289
+ const queryUrl = new URL(request.url, 'http://localhost').searchParams.get('url') || '';
1290
+ const pending = [...pendingCliAuth.keys()];
1291
+ let matched = false;
1292
+ for (const tool of pending) {
1293
+ if (tool === 'lark-cli' && queryUrl) {
1294
+ matched = true;
1295
+ break;
1296
+ }
1297
+ }
1298
+ if (!matched) {
1299
+ sendJson(response, 400, { error: '没有进行中的授权会话' });
1300
+ return;
1301
+ }
1302
+ try {
1303
+ const ascii = await cliAuthQrcode(queryUrl);
1304
+ if (!ascii) throw new Error('二维码生成失败');
1305
+ response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
1306
+ response.end(ascii);
1307
+ } catch (error) {
1308
+ sendJson(response, 502, { error: error?.message || '二维码生成失败' });
1309
+ }
1310
+ });
1311
+
1034
1312
  register('/agentone/api/plan', async (request, response) => {
1035
1313
  if (request.method !== 'GET') {
1036
1314
  response.writeHead(405, { allow: 'GET' });
@@ -1098,6 +1376,9 @@ export function apply(ctx, config) {
1098
1376
  await removePlatformModel(config).catch((error) => {
1099
1377
  log.warn(`[dsh-agentone] remove platform model failed: ${error?.message || error}`);
1100
1378
  });
1379
+ await removePlatformSkillhub(config, log).catch((error) => {
1380
+ log.warn(`[dsh-agentone] remove platform skillhub failed: ${error?.message || error}`);
1381
+ });
1101
1382
  sendJson(response, 200, { ok: true });
1102
1383
  } catch (error) {
1103
1384
  // 本地清理任何一步失败都明确报错(中文),不再静默挂起连接
@@ -1106,8 +1387,8 @@ export function apply(ctx, config) {
1106
1387
  }
1107
1388
  });
1108
1389
 
1109
- // 启动时若已有登录态:先保鲜令牌(临期轮换),再后台刷新模型配置与套餐技能。
1110
- // 内置插件常驻检测与登录无关,独立先行。
1390
+ // 启动时若已有登录态:先保鲜令牌(临期轮换),再后台刷新模型配置、
1391
+ // SkillHub 配置与套餐技能。内置插件常驻检测与登录无关,独立先行。
1111
1392
  ensureBuiltinPlugins(config, log).catch((error) => {
1112
1393
  log.warn(`[dsh-agentone] builtin plugin check failed: ${error?.message || error}`);
1113
1394
  });
@@ -1115,11 +1396,15 @@ export function apply(ctx, config) {
1115
1396
  const credentials = await ensureFreshToken(config, log);
1116
1397
  if (!credentials) return;
1117
1398
  await syncPlatformModel(config, log).catch(() => {});
1399
+ await syncPlatformSkillhub(config, log, credentials).catch(() => {});
1118
1400
  await syncPlanSkills(config, log, credentials).catch(() => {});
1119
1401
  })();
1120
1402
 
1121
1403
  return () => {
1122
1404
  pendingStates.clear();
1405
+ pendingCliAuth.clear();
1406
+ clearModelRetry();
1407
+ modelRetryAttempts = 0;
1123
1408
  log.info('[dsh-agentone] routes unmounted');
1124
1409
  };
1125
1410
  }, 'dsh-agentone: http routes');
package/lib/page.js CHANGED
@@ -69,6 +69,11 @@ button:disabled { opacity: .55; cursor: default; }
69
69
  }
70
70
  .banner.ok { background: var(--ok-weak); color: var(--ok); display: block; }
71
71
  .banner.err { background: var(--err-weak); color: var(--err); display: block; }
72
+ /* CLI 授权弹窗遮罩:全屏半透明,点遮罩关闭 */
73
+ .modal-backdrop {
74
+ position: fixed; inset: 0; z-index: 50; display: block;
75
+ background: rgba(0, 0, 0, 0.45); padding: 0 12px; overflow-y: auto;
76
+ }
72
77
  /* toast 常驻可视区顶部:页面滚动到底部时操作反馈仍可见 */
73
78
  .toast-fixed { position: sticky; top: 8px; z-index: 10; }
74
79
  /* 首屏骨架占位:慢网下 status 未回来前不至于一片空白 */
@@ -225,14 +230,14 @@ $('cli-recheck-btn').onclick = () => {
225
230
  document.querySelectorAll('.tab').forEach((tab) => {
226
231
  tab.onclick = () => activateTab(tab.dataset.tab);
227
232
  });
228
- const skillAction = (slug, action, btn) => withLoading(
233
+ const skillAction = (slug, action, btn, successText) => withLoading(
229
234
  btn,
230
235
  () => fetch('api/skill/' + action, {
231
236
  method: 'POST',
232
237
  headers: { 'Content-Type': 'application/json' },
233
238
  body: JSON.stringify({ slug }),
234
239
  }),
235
- action === 'install' ? '技能已安装' : '技能已卸载',
240
+ successText || (action === 'install' ? '技能已安装' : '技能已卸载'),
236
241
  );
237
242
  const PLAN_CAPABILITY_LABELS = {
238
243
  image_understanding_enabled: '图片理解',
@@ -360,12 +365,21 @@ const renderSkills = (s) => {
360
365
  }
361
366
  const rows = list.map((skill) => {
362
367
  const tag = skill.source === 'plan' ? '<span class="tag">套餐</span>' : '';
363
- const meta = skill.installed
364
- ? '已装 ' + esc(skill.installed_version || '')
365
- : '<span class="muted">未安装</span>';
366
- const action = skill.installed
367
- ? '<button class="btn small danger" data-uninstall="' + esc(skill.canonical_slug) + '">卸载</button>'
368
- : '<button class="btn small primary" data-install="' + esc(skill.canonical_slug) + '">安装</button>';
368
+ // 可升级:平台对比 latest 指纹/版本后给的结论,提示「v旧 → v新」;
369
+ // latest 查询失败时平台降级 false,这里不额外猜测
370
+ const upgradable = skill.installed && skill.upgrade_available;
371
+ const meta = !skill.installed
372
+ ? '<span class="muted">未安装</span>'
373
+ : upgradable
374
+ ? '已装 ' + esc(skill.installed_version || '')
375
+ + ' → <b>v' + esc(skill.latest_version || '新版本') + '</b>'
376
+ : '已装 ' + esc(skill.installed_version || '');
377
+ const action = !skill.installed
378
+ ? '<button class="btn small primary" data-install="' + esc(skill.canonical_slug) + '">安装</button>'
379
+ : upgradable
380
+ ? '<button class="btn small primary" data-upgrade="' + esc(skill.canonical_slug) + '">升级</button>'
381
+ + '<button class="btn small danger" data-uninstall="' + esc(skill.canonical_slug) + '">卸载</button>'
382
+ : '<button class="btn small danger" data-uninstall="' + esc(skill.canonical_slug) + '">卸载</button>';
369
383
  return '<div class="skill-item"><div class="skill-info"><div class="skill-name">'
370
384
  + esc(displayName(skill.canonical_slug)) + tag + '</div><div class="skill-sub">'
371
385
  + esc(skill.canonical_slug) + ' · ' + meta + '</div></div>' + action + '</div>';
@@ -375,10 +389,12 @@ const renderSkills = (s) => {
375
389
  if (s.skills_synced_at) metaLine.push('自动同步:' + new Date(s.skills_synced_at).toLocaleString());
376
390
  if (s.skills_error) metaLine.push('<span style="color:var(--err)">' + esc(s.skills_error) + '</span>');
377
391
  const metaHtml = metaLine.join(' · ');
378
- // 5s 轮询会反复调用本函数:指纹(slug:装否:版本 + meta 行)没变时跳过
379
- // innerHTML 重写,避免替换按钮节点吞掉正在进行的点击、打断卸载二次确认
392
+ // 5s 轮询会反复调用本函数:指纹(slug:装否:版本:可升级 + meta 行)没变
393
+ // 时跳过 innerHTML 重写,避免替换按钮节点吞掉正在进行的点击、打断卸载二
394
+ // 次确认
380
395
  const fingerprint = list.map((skill) =>
381
- skill.canonical_slug + ':' + (skill.installed ? 1 : 0) + ':' + (skill.installed_version || ''),
396
+ skill.canonical_slug + ':' + (skill.installed ? 1 : 0) + ':' + (skill.installed_version || '')
397
+ + ':' + (skill.upgrade_available ? 1 : 0),
382
398
  ).join('|') + '#' + metaHtml;
383
399
  if ($('skills-list').dataset.fingerprint !== fingerprint) {
384
400
  $('skills-list').dataset.fingerprint = fingerprint;
@@ -388,6 +404,11 @@ const renderSkills = (s) => {
388
404
  btn.dataset.loading = '安装中…';
389
405
  btn.onclick = () => skillAction(btn.dataset.install, 'install', btn);
390
406
  });
407
+ // 升级即覆盖式重装(staging 原子替换旧目录),成功提示与安装同口径
408
+ $('skills-list').querySelectorAll('[data-upgrade]').forEach((btn) => {
409
+ btn.dataset.loading = '升级中…';
410
+ btn.onclick = () => skillAction(btn.dataset.upgrade, 'install', btn, '技能已升级');
411
+ });
391
412
  $('skills-list').querySelectorAll('[data-uninstall]').forEach((btn) => {
392
413
  btn.dataset.loading = '卸载中…';
393
414
  btn.onclick = () => confirmThen(btn, '确认卸载?', () =>
@@ -459,19 +480,139 @@ const renderCliData = (s, tools) => {
459
480
  ? ' → <b>v' + esc(info.latest) + '</b>'
460
481
  : '');
461
482
  const upgradable = info.latest && info.latest !== info.version;
462
- const actions = upgradable
463
- ? '<button class="btn small primary" data-cli-upgrade="' + esc(name) + '">升级</button>'
464
- : '';
483
+ // 已装未授权 → 「去授权」:发起官方 Device Flow(扫码/链接 + 轮询确认)
484
+ const actions = [
485
+ state === 'unauthorized'
486
+ ? '<button class="btn small primary" data-cli-auth="' + esc(name) + '">去授权</button>'
487
+ : '',
488
+ upgradable
489
+ ? '<button class="btn small ghost" data-cli-upgrade="' + esc(name) + '">升级</button>'
490
+ : '',
491
+ ].join('');
465
492
  return '<div class="skill-item"><div class="skill-info"><div class="skill-name">'
466
493
  + esc(name) + status + '</div><div class="skill-sub">'
467
- + versionLine + '</div></div><div>' + actions + '</div></div>';
494
+ + versionLine + '</div></div><div style="display:flex;gap:6px">' + actions + '</div></div>';
468
495
  });
469
496
  body.innerHTML = rows.join('')
470
497
  + '<div class="hint">在对话中直接使用(如 /lark 帮助),由 dsh 自动调用。</div>'
471
- + '<div class="hint">未安装/未授权时,请联系管理员在镜像或本机完成安装与授权;有新版本时可在此一键升级。</div>';
498
+ + '<div class="hint">未安装时请联系管理员;已安装未授权时点「去授权」用飞书扫码完成即可。</div>';
472
499
  body.querySelectorAll('[data-cli-upgrade]').forEach((btn) => {
473
500
  btn.onclick = () => cliUpgrade(btn.dataset.cliUpgrade, btn);
474
501
  });
502
+ body.querySelectorAll('[data-cli-auth]').forEach((btn) => {
503
+ btn.onclick = () => cliAuthDialog(btn.dataset.cliAuth);
504
+ });
505
+ };
506
+
507
+ /**
508
+ * CLI 授权弹窗:begin 拿授权链接与 user_code → 展示链接 + ASCII 二维码 →
509
+ * 轮询 complete(用户在授权页同意后 CLI 落盘 token)→ 成功刷新面板。
510
+ * 弹窗关闭即停轮询;设备码只存插件后端内存,前端不回传。
511
+ */
512
+ const cliAuthDialog = async (tool) => {
513
+ const toolLabel = CLI_TOOL_META[tool]?.label || tool;
514
+ const overlay = document.createElement('div');
515
+ overlay.className = 'modal-backdrop';
516
+ overlay.innerHTML = '<div class="card" style="max-width:420px;margin:8% auto;text-align:left">'
517
+ + '<h3 style="margin:0 0 6px">授权 ' + esc(tool) + '</h3>'
518
+ + '<div id="cli-auth-status" class="skill-sub">正在发起授权…</div>'
519
+ + '<pre id="cli-auth-qr" style="display:none;background:var(--card,#fff);color:#111;'
520
+ + 'border-radius:10px;padding:8px;font:11px/1.15 ui-monospace,monospace;overflow:auto;margin:10px 0"></pre>'
521
+ + '<div id="cli-auth-link"></div>'
522
+ + '<div class="row" style="margin-top:12px;justify-content:flex-end">'
523
+ + '<button id="cli-auth-open" class="btn small primary" hidden>打开授权页</button>'
524
+ + '<button id="cli-auth-copy" class="btn small ghost" hidden>复制验证码</button>'
525
+ + '<button id="cli-auth-close" class="btn small ghost">关闭</button></div></div>';
526
+ document.body.appendChild(overlay);
527
+ let pollTimer = null;
528
+ let expiryTimer = null;
529
+ let closed = false;
530
+ const statusEl = overlay.querySelector('#cli-auth-status');
531
+ const close = () => {
532
+ closed = true;
533
+ if (pollTimer) clearTimeout(pollTimer);
534
+ if (expiryTimer) clearTimeout(expiryTimer);
535
+ overlay.remove();
536
+ };
537
+ overlay.querySelector('#cli-auth-close').onclick = close;
538
+ overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); });
539
+ try {
540
+ const resp = await fetch('api/cli/auth/begin', {
541
+ method: 'POST',
542
+ headers: { 'Content-Type': 'application/json' },
543
+ body: JSON.stringify({ tool }),
544
+ });
545
+ const data = await resp.json().catch(() => ({}));
546
+ if (!resp.ok) throw new Error(data.error || '发起授权失败');
547
+ statusEl.textContent = '用' + esc(toolLabel) + '扫二维码,或打开链接完成授权。同意后此处会自动确认。';
548
+ // ASCII 二维码(lark-cli 本机生成;失败退化为纯链接)
549
+ fetch('api/cli/auth/qrcode?url=' + encodeURIComponent(data.verification_url))
550
+ .then((r) => (r.ok ? r.text() : ''))
551
+ .then((ascii) => {
552
+ const qr = overlay.querySelector('#cli-auth-qr');
553
+ if (ascii) {
554
+ qr.textContent = ascii;
555
+ qr.style.display = 'block';
556
+ }
557
+ })
558
+ .catch(() => {});
559
+ const openBtn = overlay.querySelector('#cli-auth-open');
560
+ openBtn.hidden = false;
561
+ openBtn.onclick = () => window.open(data.verification_url, '_blank');
562
+ const copyBtn = overlay.querySelector('#cli-auth-copy');
563
+ if (data.user_code) {
564
+ copyBtn.hidden = false;
565
+ copyBtn.onclick = async () => {
566
+ try {
567
+ await navigator.clipboard.writeText(data.user_code);
568
+ copyBtn.textContent = '已复制';
569
+ } catch {
570
+ copyBtn.textContent = data.user_code;
571
+ }
572
+ };
573
+ overlay.querySelector('#cli-auth-link').innerHTML
574
+ = '<div class="skill-sub">验证码:<b>' + esc(data.user_code) + '</b></div>';
575
+ }
576
+ // 自调度串行轮询:上一次 complete 返回后才发起下一次(setInterval 会在
577
+ // 长轮询未返回时叠加并发子进程)。complete 单次后端限时 25s:
578
+ // 200 → 授权成功;202 → 仍在等待,3s 后重试;其它 → 终态错误停止。
579
+ const pollComplete = async () => {
580
+ if (closed) return;
581
+ try {
582
+ const r = await fetch('api/cli/auth/complete', {
583
+ method: 'POST',
584
+ headers: { 'Content-Type': 'application/json' },
585
+ body: JSON.stringify({ tool }),
586
+ });
587
+ const d = await r.json().catch(() => ({}));
588
+ if (r.ok && d.ok) {
589
+ statusEl.textContent = '✓ 授权成功';
590
+ setTimeout(close, 800);
591
+ if (d.tool) renderCliData(lastStatus, { [tool]: d.tool });
592
+ else renderCli(lastStatus);
593
+ return;
594
+ }
595
+ if (r.status === 202 && d.pending !== false) {
596
+ statusEl.textContent = '正在等待你在授权页完成同意…';
597
+ pollTimer = setTimeout(pollComplete, 3000);
598
+ return;
599
+ }
600
+ statusEl.textContent = d.error || '授权失败,请重新发起';
601
+ } catch {
602
+ // 网络抖动继续轮询
603
+ pollTimer = setTimeout(pollComplete, 3000);
604
+ }
605
+ };
606
+ pollTimer = setTimeout(pollComplete, 3000);
607
+ const expiresInSeconds = Number(data.expires_in) || 600;
608
+ expiryTimer = setTimeout(() => {
609
+ closed = true;
610
+ if (pollTimer) clearTimeout(pollTimer);
611
+ statusEl.textContent = '授权超时,请关闭后重试。';
612
+ }, Math.max(expiresInSeconds, 60) * 1000);
613
+ } catch (error) {
614
+ statusEl.textContent = error.message || '发起授权失败';
615
+ }
475
616
  };
476
617
 
477
618
  /** CLI 一键升级:官方 tool update(ccpg 下载二进制可达分钟级)。 */
@@ -757,6 +898,16 @@ const refresh = async () => {
757
898
  const gotoPlan = $('goto-plan-btn');
758
899
  if (gotoPlan) gotoPlan.onclick = () => activateTab('plan');
759
900
  }
901
+ if (s.logged_in && s.skillhub) {
902
+ const sh = s.skillhub;
903
+ const lines = [];
904
+ if (sh.registry) lines.push('<div class="kv"><span class="k">Registry</span><span class="v">' + esc(sh.registry) + '</span></div>');
905
+ else lines.push('<div class="kv"><span class="k">SkillHub</span><span class="v muted">未同步</span></div>');
906
+ if (sh.synced_at) lines.push('<div class="kv"><span class="k">上次同步</span><span class="v">' + new Date(sh.synced_at).toLocaleString() + '</span></div>');
907
+ if (sh.error) lines.push('<div class="kv"><span class="k">同步状态</span><span class="v" style="color:var(--err)">' + esc(sh.error) + '</span></div>');
908
+ lines.push('<div class="kv"><span class="k">环境变量</span><span class="v muted">已写入 agentone/skillhub.env(0600)</span></div>');
909
+ $('skillhub-body').innerHTML = lines.join('');
910
+ }
760
911
  renderSkills(s);
761
912
  renderAbout(s);
762
913
  };
@@ -788,6 +939,11 @@ $('sync-btn').onclick = () => withLoading(
788
939
  () => fetch('api/model/sync', { method: 'POST' }),
789
940
  '平台模型已同步',
790
941
  );
942
+ $('skillhub-sync-btn').onclick = () => withLoading(
943
+ $('skillhub-sync-btn'),
944
+ () => fetch('api/skillhub/sync', { method: 'POST' }),
945
+ 'SkillHub 配置已同步',
946
+ );
791
947
  logoutBtn.onclick = () => withLoading(
792
948
  logoutBtn,
793
949
  () => fetch('api/logout', { method: 'POST' }),
@@ -856,6 +1012,13 @@ export function statusPageHtml() {
856
1012
  <button id="sync-btn" class="btn ghost small" data-loading="同步中…">重新同步模型</button>
857
1013
  </div>
858
1014
  </div>
1015
+ <div class="card">
1016
+ <h3>SkillHub 连接 <span class="muted">技能市场与 clawhub CLI 认证</span></h3>
1017
+ <div id="skillhub-body"></div>
1018
+ <div class="row" style="margin-top:10px">
1019
+ <button id="skillhub-sync-btn" class="btn ghost small" data-loading="同步中…">重新获取 SkillHub 配置</button>
1020
+ </div>
1021
+ </div>
859
1022
  </div>
860
1023
 
861
1024
  <div class="tabpane" id="pane-plan" hidden>
@@ -0,0 +1,130 @@
1
+ // 平台 SkillHub 连接配置自动落地:对齐平台托管容器的注入语义。
2
+ //
3
+ // 平台侧(app/executor/docker_executor.py start_gateway)给托管容器注入
4
+ // SKILLHUB_BASE_URL / SKILLHUB_TOKEN + CLAWHUB_* 兼容别名,hermes 另写
5
+ // /opt/data/.env 供 Gateway 子进程读取。独立/桌面版 dsh 通过本模块拿到
6
+ // 同等配置:
7
+ // 1. fetchSkillhubConfig 拉平台 /api/plugin/v1/skillhub/config
8
+ // (registry + 用户级个人 token,平台不下发默认/admin token);
9
+ // 2. applyPlatformSkillhub 写 $DSH_HOME/agentone/skillhub.env(0600,
10
+ // 4 个变量与平台同名单)——与托管容器 .env 对等的落盘形态;
11
+ // 3. clawhubLogin 本机有 clawhub CLI 时写入登录态(与平台容器
12
+ // 内安装命令同源:clawhub login --token … --registry …);无 clawhub
13
+ // 静默跳过。
14
+ // token 明文只进 0600 的 env 文件与 credentials.json(本就 owner-only)。
15
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+
18
+ import { platformError, platformFetch } from './http.js';
19
+ import { runCliTool } from './proc.js';
20
+ import { resolveDshHome } from './store.js';
21
+
22
+ /** clawhub login/logout 超时:登录要打 registry,网络慢时放宽。 */
23
+ const CLAWHUB_TIMEOUT_MS = 60_000;
24
+
25
+ /** SkillHub token 形态校验:与平台 _is_skillhub_token 同口径(sk_ / sk- 前缀)。 */
26
+ export function isSkillhubToken(token) {
27
+ return typeof token === 'string' && /^(sk_|sk-)/.test(token);
28
+ }
29
+
30
+ export function skillhubEnvPath(config) {
31
+ return join(resolveDshHome(config), 'agentone', 'skillhub.env');
32
+ }
33
+
34
+ /** 生成 skillhub.env 文本:标准变量 + 兼容别名,与平台容器注入同名单。 */
35
+ export function buildSkillhubEnv({ registryUrl, token }) {
36
+ return [
37
+ '# AgentOne 平台 SkillHub 配置(插件自动生成,登出时删除)',
38
+ `SKILLHUB_BASE_URL=${registryUrl}`,
39
+ `SKILLHUB_TOKEN=${token}`,
40
+ `CLAWHUB_REGISTRY=${registryUrl}`,
41
+ `CLAWHUB_TOKEN=${token}`,
42
+ '',
43
+ ].join('\n');
44
+ }
45
+
46
+ /** 拉平台 SkillHub 连接配置;enabled=false 或失败时返回 { enabled:false }。 */
47
+ export async function fetchSkillhubConfig(platformUrl, accessToken) {
48
+ const response = await platformFetch(
49
+ `${platformUrl.replace(/\/+$/, '')}/api/plugin/v1/skillhub/config`,
50
+ { headers: { Authorization: `Bearer ${accessToken}` } },
51
+ );
52
+ if (!response.ok) {
53
+ throw await platformError(response, '获取 SkillHub 配置失败');
54
+ }
55
+ return response.json();
56
+ }
57
+
58
+ async function writeFileAtomic(path, text, mode) {
59
+ await mkdir(join(path, '..'), { recursive: true, mode: 0o700 });
60
+ const tmp = `${path}.agentone-${process.pid}-${Date.now()}.tmp`;
61
+ await writeFile(tmp, text, { mode });
62
+ await rename(tmp, path);
63
+ }
64
+
65
+ /**
66
+ * 拉取并落地 SkillHub 配置(env 文件 + clawhub 登录态)。返回
67
+ * { registryUrl, token }。失败抛错(中文 message),由调用方记录到
68
+ * credentials 的 skillhub_error,不阻断登录/启动。
69
+ */
70
+ export async function applyPlatformSkillhub(config, platformUrl, accessToken) {
71
+ const data = await fetchSkillhubConfig(platformUrl, accessToken);
72
+ if (!data?.enabled) {
73
+ throw new Error('平台未启用 SkillHub');
74
+ }
75
+ const registryUrl = String(data.registry_url || '').replace(/\/+$/, '');
76
+ const token = String(data.token || '');
77
+ if (!registryUrl || !isSkillhubToken(token)) {
78
+ throw new Error('平台返回的 SkillHub 配置不完整');
79
+ }
80
+
81
+ await writeFileAtomic(skillhubEnvPath(config), buildSkillhubEnv({ registryUrl, token }), 0o600);
82
+ // 插件自身进程 env 也设置一份:插件派生的子进程直接继承
83
+ process.env.SKILLHUB_BASE_URL = registryUrl;
84
+ process.env.SKILLHUB_TOKEN = token;
85
+ process.env.CLAWHUB_REGISTRY = registryUrl;
86
+ process.env.CLAWHUB_TOKEN = token;
87
+
88
+ await clawhubLogin(registryUrl, token).catch(() => {});
89
+ return { registryUrl, token };
90
+ }
91
+
92
+ /** 本机 clawhub 写登录态;未安装返回 false(静默跳过),其余错误上抛由调用方容错。 */
93
+ export async function clawhubLogin(registryUrl, token) {
94
+ try {
95
+ await runCliTool(
96
+ 'clawhub',
97
+ ['login', '--token', token, '--registry', registryUrl],
98
+ { timeoutMs: CLAWHUB_TIMEOUT_MS },
99
+ );
100
+ return true;
101
+ } catch (error) {
102
+ if (/未找到 clawhub 命令/.test(error?.message || '')) return false;
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ /** 登出清理:删 env 文件、清进程 env、尽力 clawhub logout。 */
108
+ export async function removePlatformSkillhub(config, log) {
109
+ await rm(skillhubEnvPath(config), { force: true }).catch(() => {});
110
+ for (const key of ['SKILLHUB_BASE_URL', 'SKILLHUB_TOKEN', 'CLAWHUB_REGISTRY', 'CLAWHUB_TOKEN']) {
111
+ delete process.env[key];
112
+ }
113
+ try {
114
+ await runCliTool('clawhub', ['logout'], { timeoutMs: CLAWHUB_TIMEOUT_MS });
115
+ } catch {
116
+ // 无 clawhub 或登出失败都不阻断登出
117
+ }
118
+ if (log) log.info('[dsh-agentone] local skillhub config removed');
119
+ }
120
+
121
+ /** 读取 env 文件内容(测试与状态展示用;文件不存在返回 null)。 */
122
+ export async function readSkillhubEnv(config) {
123
+ try {
124
+ const text = await readFile(skillhubEnvPath(config), 'utf8');
125
+ await chmod(skillhubEnvPath(config), 0o600).catch(() => {});
126
+ return text;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
package/lib/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // 平台凭据持久化:$DSH_HOME/agentone/credentials.json(0600,仅本机插件可读)。
2
- import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
 
@@ -15,6 +15,26 @@ function credentialsPath(config) {
15
15
  return join(storeDir(config), 'credentials.json');
16
16
  }
17
17
 
18
+ /**
19
+ * 凭据更新互斥链:启动块、登录回调、手动同步、自愈重试可能在同一进程
20
+ * 内并发执行 read-merge-write(updateCredentials)。无锁时两个并发写会
21
+ * 互相丢补丁,且 writeFile 非原子——读到半截 JSON 会触发 loadCredentials
22
+ * 的容错把文件当损坏移开(登录态直接丢失)。这里把所有更新串行化,
23
+ * 写入改为 tmp + rename 原子替换,任一时刻磁盘上都是完整 JSON。
24
+ */
25
+ let credentialsWriteChain = Promise.resolve();
26
+
27
+ function serializedWrite(config, writeFn) {
28
+ const next = credentialsWriteChain.then(writeFn, writeFn);
29
+ // 链上错误不阻断后续更新,也不进调用方的结果
30
+ credentialsWriteChain = next.catch(() => {});
31
+ return next;
32
+ }
33
+
34
+ function tmpName(path) {
35
+ return `${path}.tmp-${process.pid}-${Date.now()}`;
36
+ }
37
+
18
38
  /**
19
39
  * 读取凭据。文件损坏(截断/手改坏)不抛出:把坏文件移开为
20
40
  * credentials.json.corrupt,返回 null 回到未登录态——否则 logout 也依赖
@@ -40,21 +60,32 @@ export async function loadCredentials(config) {
40
60
  }
41
61
  }
42
62
 
43
- export async function saveCredentials(config, credentials) {
44
- const dir = storeDir(config);
45
- await mkdir(dir, { recursive: true, mode: 0o700 });
46
- await writeFile(credentialsPath(config), JSON.stringify(credentials, null, 2) + '\n', {
47
- mode: 0o600,
48
- flag: 'w',
63
+ export function saveCredentials(config, credentials) {
64
+ return serializedWrite(config, async () => {
65
+ const dir = storeDir(config);
66
+ await mkdir(dir, { recursive: true, mode: 0o700 });
67
+ const path = credentialsPath(config);
68
+ const tmp = tmpName(path);
69
+ await writeFile(tmp, JSON.stringify(credentials, null, 2) + '\n', { mode: 0o600, flag: 'w' });
70
+ await rename(tmp, path);
49
71
  });
50
72
  }
51
73
 
52
74
  /** 读取-合并-写回:模型同步结果等增量字段落盘。 */
53
- export async function updateCredentials(config, patch) {
54
- const current = (await loadCredentials(config)) || {};
55
- await saveCredentials(config, { ...current, ...patch });
75
+ export function updateCredentials(config, patch) {
76
+ return serializedWrite(config, async () => {
77
+ const current = (await loadCredentials(config)) || {};
78
+ const path = credentialsPath(config);
79
+ const merged = { ...current, ...patch };
80
+ const dir = storeDir(config);
81
+ await mkdir(dir, { recursive: true, mode: 0o700 });
82
+ const tmp = tmpName(path);
83
+ await writeFile(tmp, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600, flag: 'w' });
84
+ await rename(tmp, path);
85
+ return merged;
86
+ });
56
87
  }
57
88
 
58
- export async function clearCredentials(config) {
59
- await rm(credentialsPath(config), { force: true });
89
+ export function clearCredentials(config) {
90
+ return serializedWrite(config, () => rm(credentialsPath(config), { force: true }));
60
91
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agentone",
3
- "version": "0.5.16",
4
- "description": "AgentOne 平台集成插件:飞书登录、平台模型、SkillHub 技能、套餐申请、插件管理与自助升级、lark-cli / ccpg-cli 探测(请求超时与错误码透传、令牌轮换 single-flight、凭据损坏容错、状态页 UX 增强)",
3
+ "version": "0.5.18",
4
+ "description": "AgentOne 平台集成插件:飞书登录、平台模型(同步失败自愈重试)、SkillHub 技能(含升级检测与一键升级)、套餐申请、插件管理与自助升级、lark-cli / ccpg-cli 探测(请求超时与错误码透传、令牌轮换 single-flight、凭据损坏容错与并发安全写入、状态页 UX 增强)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {