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/README.md CHANGED
@@ -1,20 +1,21 @@
1
1
  # dsh-agentone
2
2
 
3
3
  AgentOne 平台集成插件(dsh web 版 / 桌面版通用)。安装后让独立 dsh 接入公司
4
- AgentOne 平台:飞书登录、平台模型、SkillHub 技能、套餐申请、dsh 插件管理、
4
+ AgentOne 平台:IDOne 登录、平台模型、SkillHub 技能、套餐申请、dsh 插件管理、
5
5
  lark-cli / ccpg-cli 状态探测。
6
6
 
7
7
  ## 能力总览(M1–M4 已全部交付)
8
8
 
9
- - **飞书登录(M1)**:设备登录流(一次性授权码),凭据保存在本机
10
- `$DSH_HOME/agentone/credentials.json`(0600)。access 令牌临期自动轮换
11
- (single-flight),refresh 失效自动回到未登录态。
9
+ - **平台登录(M1)**:设备登录流(一次性授权码),入口按平台能力面自动
10
+ 路由——平台开启邮箱锚点模式(IDOne 统一登录)时走 IDOne,否则回退
11
+ 飞书;凭据保存在本机 `$DSH_HOME/agentone/credentials.json`(0600)。
12
+ access 令牌临期自动轮换(single-flight),refresh 失效自动回到未登录态。
12
13
  - **平台模型(M2)**:登录后自动把 dsh 的 llm provider 指向 AgentOne
13
14
  OpenAI 兼容代理(`/api/plugin/v1`),模型密钥不下发到本机;对话经平台
14
15
  走 TokenHub 按用户计量。平台 403 的 `plan_required` 错误码原样透传,
15
16
  前端据此引导申请套餐(不依赖中文文案匹配)。
16
- - **SkillHub 技能(M3)**:套餐技能自动装齐(启动/登录时,且常驻期间
17
- 套餐生效、套餐新配技能时由前端触发器与手动「同步套餐技能」按钮补装);
17
+ - **SkillHub 技能(M3)**:套餐技能与管理员默认技能在启动/登录和常驻后台同步,
18
+ 支持 `611/c611-ops-workbench`、`611--c611-ops-workbench` 与全局技能名;
18
19
  技能市场搜索、手动安装/卸载,装/卸载后技能列表即时刷新(失效 45s
19
20
  status 缓存);已装技能平台侧对比 latest 版本/指纹,有更新时展示
20
21
  「v旧 → v新」并支持一键升级(覆盖式重装,原子替换旧目录);技能包
@@ -26,11 +27,17 @@ lark-cli / ccpg-cli 状态探测。
26
27
  登出时全部清理。会话内使用:`source $DSH_HOME/agentone/skillhub.env`。
27
28
  - **套餐申请(M3+)**:状态页「套餐」tab 展示当前套餐/审批中/可申请列表,
28
29
  一键提交申请;审批通过后前端轮询检测到 active 自动同步模型。
29
- - **dsh 插件管理(M3+)**:列出镜像内置与用户自装插件(版本/升级/激活态),
30
- 内置插件缺失时启动自动重装(自身也在常驻保证清单内,防误删导致平台集成
31
- 失效);desktop 受管 pnpm 通道操作串行排队,宿主并发锁占用时自动退避
32
- 重试(不把「another desktop pnpm operation is already running」抛给用户);
33
- 自身随平台镜像升级(列表中不提供按钮)。
30
+ - **平台默认能力(#479)**:管理员在「集成开关 → dsh 插件」单独保存默认
31
+ 技能、npm 插件、用途说明和安装期构建授权。技能和插件始终安装最新发布版本,
32
+ 受管内容有新版时自动升级;客户端启动、登录及约每 5 分钟后台检查。
33
+ 本机状态由目录、版本和归属记录共同确认,用户级台账作为参考。
34
+ 目录已有用户文件、账号/registry 不同时等待用户确认应用;停用只停止管理,
35
+ 不删除本机内容。平台暂时不可用时缓存仅展示,不用于安装。
36
+ - **dsh 插件管理(M3+)**:列出当前 profile 的包、版本、bundle 注册状态;
37
+ 安装成功需物理版本、插件声明和 profile 注册同时成立。注册不证明运行时加载,
38
+ 页面提示重启生效。Web 必须配置实际 `profileName`,Desktop 使用宿主当前 profile
39
+ 和受管 pnpm 通道;安装/卸载串行排队、支持生命周期取消。自身与 DSH 基础组件
40
+ 不属于平台默认清单,自身只在独立安装模式允许手动升级。
34
41
  - **CLI 探测、安装与授权(M4)**:`lark-cli` / `ccpg-cli` 安装与授权态三态探测;
35
42
  未安装时「安装」一键自助安装(lark 走 `npm install -g @larksuite/cli@latest`,
36
43
  ccpg 走公司 `auto-install.sh`,口径与平台 cli-deployd 一致);
@@ -46,7 +53,7 @@ lark-cli / ccpg-cli 状态探测。
46
53
  每个会话基线加载,project 级指令优先);幂等不重复注入,用户已有语言
47
54
  偏好或删除标记块后不再介入(本机只做一次决策,记录于
48
55
  `$DSH_HOME/agentone/language-default.json`)。
49
- - **首启引导**:dsh 首次启动弹出飞书登录引导(order -50,早于官方
56
+ - **首启引导**:dsh 首次启动弹出平台登录引导(order -50,早于官方
50
57
  「添加 API Key」),登录一次即跳过官方密钥引导;暗色主题适配。
51
58
 
52
59
  所有平台请求统一走 `lib/http.js`(默认 10s 超时、技能包下载 60s),
@@ -63,8 +70,9 @@ dsh plugin --profile web add dsh-agentone
63
70
 
64
71
  ## 使用
65
72
 
66
- 打开 dsh web 的 `/agentone/` 页面(或 dsh 设置 → AgentOne)→「飞书登录」→
67
- 浏览器完成平台飞书 OAuth → 自动跳回并显示已登录账号。授权完成页会等待
73
+ 打开 dsh web 的 `/agentone/` 页面(或 dsh 设置 → AgentOne)→「IDOne 登录」
74
+ (平台未开启统一登录时为「飞书登录」)→ 浏览器完成平台 OAuth → 自动跳回
75
+ 并显示已登录账号。授权完成页会等待
68
76
  模型配置就绪后自动关闭;配置失败时展示原因并提供「重试同步」。
69
77
 
70
78
  登录成功后插件自动完成模型配置(也可点「重新同步模型」手动触发):
@@ -88,8 +96,8 @@ dsh plugin --profile web add dsh-agentone
88
96
 
89
97
  ```
90
98
  插件页 ──POST /agentone/api/login──▶ 本插件后端
91
- ◀─ {login_url} ──
92
- 浏览器 ──login_url──▶ 平台飞书 OAuth ──▶ /api/auth/plugin/landing
99
+ ◀─ {login_url, provider} ──
100
+ 浏览器 ──login_url──▶ 平台 OAuth(IDOne 优先,飞书兜底)──▶ /api/auth/plugin/landing
93
101
  landing(JS, Bearer) ──POST /api/auth/plugin/grant──▶ {redirect_url(一次性 code)}
94
102
  浏览器 ──redirect_url──▶ 本插件 /agentone/callback?code
95
103
  插件后端 ──POST /api/auth/plugin/exchange──▶ {access_token}
@@ -105,6 +113,7 @@ dsh plugin --profile web add dsh-agentone
105
113
  | --- | --- | --- |
106
114
  | `platformUrl` | `https://ccpg.one.agentone.work` | AgentOne 平台地址(本地开发用 profile `cordis.patch.yml` 覆盖为 `http://127.0.0.1:8558`) |
107
115
  | `storeDir` | `$DSH_HOME/agentone` | 凭据目录 |
116
+ | `profileName` | 空 | Web 模式填写实际启动 profile(例如 web 或自定义名);无法确定时暂停插件写入。Desktop 自动读取当前 profile |
108
117
 
109
118
  密钥类值一律不进 settings,仅落 `credentials.json`(0600)。
110
119
 
package/lib/auth.js CHANGED
@@ -1,10 +1,12 @@
1
1
  // AgentOne 设备登录流(一次性授权码)。
2
2
  //
3
3
  // 流程(与平台 app/api/plugin_auth.py 契约对应):
4
- // 1. buildLoginUrl:拼平台飞书 OAuth 入口,redirect 指向平台 landing 页并
5
- // 携带 callback_url/state(经飞书 OAuth state 往返透传);
6
- // 2. 浏览器完成飞书登录 → landing 页用平台令牌换一次性 code → 跳转
7
- // callback_url(即本插件的 /agentone/callback 路由);
4
+ // 1. buildLoginUrl:拼平台 OAuth 入口(IDOne 优先、飞书兜底,见
5
+ // pickLoginProvider),redirect 指向平台 landing 页并携带
6
+ // callback_url/state(经 OAuth state 往返透传);
7
+ // 2. 浏览器完成登录 → 平台回调页写 hermeshub_token → landing 页用平台
8
+ // 令牌换一次性 code → 跳转 callback_url(即本插件的 /agentone/callback
9
+ // 路由);
8
10
  // 3. exchangeCode:code 换新签发的平台访问令牌。
9
11
  import { randomBytes } from 'node:crypto';
10
12
 
@@ -14,10 +16,37 @@ export function newLoginState() {
14
16
  return randomBytes(16).toString('hex');
15
17
  }
16
18
 
17
- export function buildLoginUrl(platformUrl, callbackUrl, state) {
19
+ export function buildLoginUrl(platformUrl, callbackUrl, state, provider = 'feishu') {
18
20
  const landing = `/api/auth/plugin/landing?callback_url=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}`;
19
21
  const redirect = encodeURIComponent(landing);
20
- return `${platformUrl.replace(/\/+$/, '')}/api/auth/feishu/login?redirect=${redirect}`;
22
+ const entry = provider === 'idone' ? '/api/auth/casdoor/login' : '/api/auth/feishu/login';
23
+ return `${platformUrl.replace(/\/+$/, '')}${entry}?redirect=${redirect}`;
24
+ }
25
+
26
+ /**
27
+ * 匿名拉取平台能力面(登录入口开关)。登录前无令牌,走公开的
28
+ * /api/auth/capabilities(门户登录页同源依赖)。任何失败(超时/非 2xx/
29
+ * 解析错)返回 null,调用方按能力未知处理。
30
+ */
31
+ export async function fetchAuthCapabilities(platformUrl) {
32
+ try {
33
+ const response = await platformFetch(`${platformUrl.replace(/\/+$/, '')}/api/auth/capabilities`, {}, 3000);
34
+ if (!response.ok) return null;
35
+ return await response.json();
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 按 capabilities 选登录入口:仅当 IDOne 已启用且邮箱锚点模式开启
43
+ * (平台统一登录语义,未绑定用户由后端按邮箱自动认人)才走 IDOne;
44
+ * 其余情况(未启用 / 非锚点 / 查询失败)一律回退飞书。
45
+ */
46
+ export function pickLoginProvider(caps) {
47
+ return caps?.casdoor_oauth_enabled === true && caps?.casdoor_email_anchor_enabled === true
48
+ ? 'idone'
49
+ : 'feishu';
21
50
  }
22
51
 
23
52
  export async function exchangeCode(platformUrl, code) {
package/lib/client.js CHANGED
@@ -176,6 +176,8 @@ window.__ModuleLoader__.load({
176
176
  const { complete } = props;
177
177
  const [phase, setPhase] = React.useState('checking'); // checking | idle | waiting
178
178
  const [error, setError] = React.useState('');
179
+ // 登录入口名(服务端按平台能力面路由:IDOne 优先、飞书兜底)
180
+ const [providerName, setProviderName] = React.useState('飞书');
179
181
 
180
182
  React.useEffect(() => {
181
183
  let cancelled = false;
@@ -183,8 +185,10 @@ window.__ModuleLoader__.load({
183
185
  try {
184
186
  const response = await fetch('/agentone/api/status');
185
187
  const status = await response.json();
186
- if (!cancelled && status?.logged_in) complete();
187
- else if (!cancelled) setPhase('idle');
188
+ if (cancelled) return;
189
+ if (status?.login_provider === 'idone') setProviderName('IDOne');
190
+ if (status?.logged_in) complete();
191
+ else setPhase('idle');
188
192
  } catch {
189
193
  if (!cancelled) setPhase('idle');
190
194
  }
@@ -245,8 +249,8 @@ window.__ModuleLoader__.load({
245
249
  'p',
246
250
  { style: bodyStyle },
247
251
  phase === 'waiting'
248
- ? '已打开授权窗口,完成飞书授权后这里会自动继续…'
249
- : '使用公司飞书账号登录,公司托管的模型与技能会自动配置,无需任何 API Key。',
252
+ ? `已打开授权窗口,完成${providerName}授权后这里会自动继续…`
253
+ : `使用公司${providerName}账号登录,公司托管的模型与技能会自动配置,无需任何 API Key。`,
250
254
  ),
251
255
  error
252
256
  ? React.createElement('p', { style: { ...bodyStyle, color: '#c0392b' } }, error)
@@ -261,7 +265,7 @@ window.__ModuleLoader__.load({
261
265
  onClick: startLogin,
262
266
  disabled: phase === 'waiting',
263
267
  },
264
- phase === 'waiting' ? '等待授权…' : '飞书登录',
268
+ phase === 'waiting' ? '等待授权…' : `${providerName}登录`,
265
269
  ),
266
270
  React.createElement(
267
271
  'button',
@@ -0,0 +1,257 @@
1
+ // apply 范围内的默认能力协调器:后台检查、single-flight、身份隔离和可取消生命周期。
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ import { platformError, platformFetch } from './http.js';
6
+ import { fetchPlatformConfig, validatePlatformConfig } from './platform-config.js';
7
+ import { installPlatformSkill, listPlatformSkills } from './skill.js';
8
+ import { localSkillState } from './skill-state.js';
9
+ import { resolveDshHome } from './store.js';
10
+
11
+ export function createDefaultsSync({ config, profiles, credentials, log, intervalMs = 300_000,
12
+ readConfig = fetchPlatformConfig, readSkills = listPlatformSkills, installSkill = installPlatformSkill,
13
+ now = () => new Date().toISOString(), setTimer = setTimeout, clearTimer = clearTimeout }) {
14
+ let disposed = false;
15
+ let generation = 0;
16
+ let inFlight = null;
17
+ let flightGeneration = null;
18
+ let timer = null;
19
+ let abort = null;
20
+ const manualOperations = new Map();
21
+ let state = { source: null, revision: null, checked_at: null, user_id: null, error: null,
22
+ running: false, skills: [], plugins: [] };
23
+ const active = (value) => !disposed && generation === value;
24
+ function stop() {
25
+ generation += 1; abort?.abort(); abort = null;
26
+ for (const controller of manualOperations.values()) controller.abort();
27
+ if (timer !== null) clearTimer(timer);
28
+ timer = null;
29
+ }
30
+ function schedule() {
31
+ if (disposed || timer !== null) return;
32
+ timer = setTimer(() => { timer = null; void sync().catch(() => {}); }, intervalMs + Math.floor(Math.random() * intervalMs * 0.1));
33
+ timer?.unref?.();
34
+ }
35
+ async function context(controller) {
36
+ const current = await credentials();
37
+ if (!current?.access_token) throw new Error('请先登录 AgentOne 平台');
38
+ const platform = (current.platform_url || config.platformUrl).replace(/\/+$/, '');
39
+ // 实例凭据仅能访问 plugin API;旧后端缺少身份字段时暂停新同步。
40
+ const instance = current.source === 'instance';
41
+ const response = await platformFetch(`${platform}${instance ? '/api/plugin/v1/config' : '/api/me'}`, {
42
+ signal: controller.signal, headers: { Authorization: `Bearer ${current.access_token}` },
43
+ });
44
+ if (!response.ok) throw await platformError(response, '登录身份暂时无法确认,请重新登录');
45
+ const identity = await response.json();
46
+ const userId = instance ? identity.user_id : identity.id;
47
+ if (typeof userId !== 'string' || !userId) throw new Error('平台未返回有效身份,请检查平台地址');
48
+ const verifiedConfig = instance ? { document: validatePlatformConfig(identity, userId), source: 'platform' } : null;
49
+ let target = null;
50
+ let profileError = null;
51
+ try { target = profiles.target(); } catch (error) { profileError = error.message; }
52
+ const home = resolve(target?.home || config.dshHome || resolveDshHome());
53
+ return { platform, token: current.access_token, userId, verifiedConfig, home,
54
+ profile: target?.name || 'unknown', profileDir: target?.dir || null, profileError };
55
+ }
56
+ function cachePath(owner) {
57
+ const hash = createHash('sha256').update(JSON.stringify([owner.platform, owner.userId, owner.home, owner.profile])).digest('hex');
58
+ return join(owner.home, 'agentone', 'defaults-state', `${hash}.json`);
59
+ }
60
+ async function writeCache(owner, document, source) {
61
+ const file = cachePath(owner); await mkdir(join(file, '..'), { recursive: true, mode: 0o700 });
62
+ const temp = `${file}.${randomUUID()}.tmp`;
63
+ await writeFile(temp, JSON.stringify({ document, source, checked_at: state.checked_at }) + '\n', { mode: 0o600 });
64
+ await rename(temp, file);
65
+ }
66
+ async function readCache(owner) {
67
+ try {
68
+ const cached = JSON.parse(await readFile(cachePath(owner), 'utf8'));
69
+ return { ...cached, document: validatePlatformConfig(cached.document, owner.userId) };
70
+ } catch { return null; }
71
+ }
72
+ async function run(manual, selected) {
73
+ const currentGeneration = generation;
74
+ const controller = new AbortController(); abort = controller;
75
+ state = { ...state, running: true, error: null };
76
+ let owner;
77
+ try {
78
+ owner = await context(controller);
79
+ if (!active(currentGeneration)) return;
80
+ if (state.user_id !== owner.userId || state.platform !== owner.platform || state.profile !== owner.profile) {
81
+ profiles.setPolicy([]);
82
+ state = { source: null, revision: null, checked_at: null,
83
+ user_id: owner.userId, platform: owner.platform, profile: owner.profile,
84
+ error: null, running: true, skills: [], plugins: [] };
85
+ }
86
+ let result;
87
+ try { result = owner.verifiedConfig || await readConfig(owner.platform, owner.token, owner.userId, controller.signal); }
88
+ catch (error) {
89
+ if (error.status !== 401 && error.status !== 403) {
90
+ const cached = await readCache(owner);
91
+ if (cached && active(currentGeneration)) {
92
+ const local = owner.profileError ? { plugins: [] } : await profiles.list();
93
+ state = { ...state, source: 'cache', revision: cached.document.revision,
94
+ checked_at: cached.checked_at, user_id: owner.userId, home: owner.home,
95
+ registry: cached.document.skill_registry, auto_sync: cached.document.auto_sync,
96
+ plugins: cached.document.plugins.map((item) => ({ kind: 'plugin', name: item.name,
97
+ description: item.description, target_version: item.version,
98
+ version: local.plugins.find((entry) => entry.name === item.name)?.version || null,
99
+ state: 'pending', error: '平台暂时不可用,此项为缓存参考' })),
100
+ skills: await Promise.all(cached.document.skills.map(async (item) => {
101
+ const local = await localSkillState(owner.platform, item.canonical_slug, {
102
+ config: { ...config, dshHome: owner.home }, userId: owner.userId, registry: cached.document.skill_registry });
103
+ return { kind: 'skill', name: item.canonical_slug, description: item.description,
104
+ state: local.state, version: local.record?.version || null, sources: ['builtin'] };
105
+ })) };
106
+ }
107
+ } else {
108
+ profiles.setPolicy([]);
109
+ state = { source: null, revision: null, checked_at: null, user_id: null,
110
+ error: null, running: true, skills: [], plugins: [] };
111
+ }
112
+ // 缓存只展示;本轮失败不进行任何新安装。
113
+ throw error;
114
+ }
115
+ if (!active(currentGeneration)) return;
116
+ const document = result.document;
117
+ profiles.setPolicy(document.plugins);
118
+ state = { ...state, source: result.source, revision: document.revision, user_id: owner.userId,
119
+ checked_at: now(), profile: owner.profile, home: owner.home, registry: document.skill_registry,
120
+ auto_sync: document.auto_sync, profile_error: owner.profileError, skills: [], plugins: [] };
121
+ await writeCache(owner, document, result.source);
122
+ const beforeCommit = async () => {
123
+ const identity = await context(controller);
124
+ if (!active(currentGeneration) || identity.userId !== owner.userId || identity.platform !== owner.platform
125
+ || identity.home !== owner.home || identity.profile !== owner.profile) throw new Error('登录身份或当前 profile 已改变,请重新同步');
126
+ const fresh = identity.verifiedConfig || await readConfig(owner.platform, owner.token, owner.userId, controller.signal);
127
+ if (JSON.stringify(fresh.document) !== JSON.stringify(document)) throw new Error('平台清单已更新,请重新检查后应用');
128
+ };
129
+ const options = { config: { ...config, dshHome: owner.home }, userId: owner.userId,
130
+ registry: document.skill_registry, signal: controller.signal, beforeCommit,
131
+ isCurrent: () => active(currentGeneration), missingOnly: !(selected && selected.apply) };
132
+ const enabled = manual || document.auto_sync;
133
+ if (!owner.profileError) {
134
+ const local = await profiles.list();
135
+ for (const item of document.plugins) {
136
+ if (!active(currentGeneration)) return;
137
+ const selectedItem = !selected || (selected.kind === 'plugin' && selected.name === item.name);
138
+ const installed = local.plugins.find((entry) => entry.name === item.name);
139
+ let entry = { kind: 'plugin', name: item.name, description: item.description,
140
+ target_version: item.version, version: installed?.version || null, loaded: null,
141
+ state: installed?.registered ? (item.version !== 'latest' && installed.version !== item.version ? 'pending' : 'current') : 'pending' };
142
+ state.plugins.push(entry);
143
+ if (!enabled && !installed?.registered) continue;
144
+ try {
145
+ const exact = await profiles.resolveVersion?.(item.name, 'latest') || 'latest';
146
+ entry.target_version = exact;
147
+ if (installed?.registered && installed.version !== exact && (!enabled || !selectedItem)) {
148
+ entry.state = 'pending';
149
+ continue;
150
+ }
151
+ if (!enabled || !selectedItem) continue;
152
+ entry.state = 'running';
153
+ const after = await profiles.install(item.name, exact, { ...options, updateLatest: true, allowBuild: item.allow_build });
154
+ if (!active(currentGeneration)) return;
155
+ Object.assign(entry, { version: after.version, state: after.state, registered: after.registered, loaded: null });
156
+ } catch (error) {
157
+ if (!active(currentGeneration)) return;
158
+ Object.assign(entry, { state: /本地链接/.test(error.message) ? 'conflict' : 'failed', error: error.message });
159
+ }
160
+ }
161
+ }
162
+ // 套餐语义独立于默认能力 auto_sync;sources 为空的纯台账项不补装。
163
+ const listing = await readSkills(owner.platform, owner.token, { signal: controller.signal });
164
+ if (!active(currentGeneration)) return;
165
+ if (listing.enabled === false || document.skills_enabled === false) return;
166
+ if (!Array.isArray(listing.skills)) throw new Error('平台技能清单格式异常');
167
+ for (const item of listing.skills) {
168
+ if (!active(currentGeneration)) return;
169
+ const sources = Array.isArray(item.sources) ? item.sources
170
+ : item.source === 'plan' ? ['plan'] : [];
171
+ if (!sources.includes('plan') && !sources.includes('builtin')) continue;
172
+ const selectedItem = !selected || (selected.kind === 'skill' && selected.name === item.canonical_slug);
173
+ let entry = { kind: 'skill', name: item.canonical_slug, sources,
174
+ description: document.skills.find((skill) => skill.canonical_slug === item.canonical_slug)?.description || '', state: 'pending' };
175
+ state.skills.push(entry);
176
+ try {
177
+ const local = await localSkillState(owner.platform, item.canonical_slug, options);
178
+ if (!active(currentGeneration)) return;
179
+ Object.assign(entry, { state: local.state, version: local.record?.version || null });
180
+ if (local.state === 'conflict' && (options.missingOnly || !selectedItem)) { entry.error = '本机技能已有用户内容或来源不一致,请确认后手动应用'; continue; }
181
+ if (local.state === 'current' && (!item.latest_version || !item.latest_fingerprint) && options.missingOnly) {
182
+ entry.state = 'failed'; entry.error = '暂时无法确认技能最新版本,请稍后重试'; continue;
183
+ }
184
+ if (local.record && ['current', 'report_pending'].includes(local.state) && (options.missingOnly || !selectedItem)
185
+ && item.latest_version && item.latest_fingerprint
186
+ && (local.record.fingerprint !== item.latest_fingerprint || local.record.version !== item.latest_version)) {
187
+ entry.target_version = item.latest_version;
188
+ if ((!enabled && !sources.includes('plan')) || !selectedItem) { entry.state = 'pending'; continue; }
189
+ }
190
+ if ((!enabled && !sources.includes('plan')) || !selectedItem) continue;
191
+ entry.state = 'running';
192
+ const installed = await installSkill(owner.platform, owner.token, item.canonical_slug,
193
+ { ...options, updateLatest: true, latestVersion: item.latest_version, latestFingerprint: item.latest_fingerprint,
194
+ source: sources.includes('plan') ? 'plan' : 'builtin' });
195
+ if (!active(currentGeneration)) return;
196
+ Object.assign(entry, installed);
197
+ } catch (error) {
198
+ if (!active(currentGeneration)) return;
199
+ Object.assign(entry, { state: 'failed', error: error.message });
200
+ }
201
+ }
202
+ } catch (error) {
203
+ if (active(currentGeneration)) {
204
+ if (error.status === 401 || error.status === 403) {
205
+ profiles.setPolicy([]);
206
+ state = { source: null, revision: null, checked_at: null, user_id: null,
207
+ skills: [], plugins: [], running: true };
208
+ }
209
+ state.error = error.message; log?.warn(`[dsh-agentone] defaults sync: ${error.message}`);
210
+ }
211
+ } finally {
212
+ if (active(currentGeneration)) { state.running = false; abort = null; schedule(); }
213
+ }
214
+ return structuredClone(state);
215
+ }
216
+ function sync({ manual = false, item = null } = {}) {
217
+ if (disposed) return Promise.resolve(structuredClone(state));
218
+ if (inFlight) {
219
+ if (flightGeneration === generation && !item) return inFlight;
220
+ const queuedGeneration = generation;
221
+ return inFlight.then(() => active(queuedGeneration) ? sync({ manual, item }) : structuredClone(state));
222
+ }
223
+ const promise = run(manual, item);
224
+ flightGeneration = generation;
225
+ inFlight = promise;
226
+ promise.finally(() => { if (inFlight === promise) inFlight = null; }).catch(() => {});
227
+ return promise;
228
+ }
229
+ function manualOperation(operation) {
230
+ const controller = new AbortController();
231
+ const currentGeneration = generation;
232
+ const promise = (async () => {
233
+ const owner = await context(controller);
234
+ const result = owner.verifiedConfig || await readConfig(owner.platform, owner.token, owner.userId, controller.signal);
235
+ const beforeCommit = async () => {
236
+ const freshOwner = await context(controller);
237
+ if (!active(currentGeneration) || freshOwner.userId !== owner.userId || freshOwner.platform !== owner.platform
238
+ || freshOwner.home !== owner.home || freshOwner.profile !== owner.profile) throw new Error('登录身份或当前 profile 已改变,请重新操作');
239
+ const fresh = freshOwner.verifiedConfig || await readConfig(owner.platform, freshOwner.token, freshOwner.userId, controller.signal);
240
+ if (JSON.stringify(fresh.document) !== JSON.stringify(result.document)) throw new Error('平台清单已更新,请重新检查后操作');
241
+ };
242
+ await beforeCommit();
243
+ profiles.setPolicy(result.document.plugins);
244
+ return operation(owner, { document: result.document, config: { ...config, dshHome: owner.home }, userId: owner.userId,
245
+ registry: result.document.skill_registry, signal: controller.signal, beforeCommit,
246
+ isCurrent: () => active(currentGeneration) });
247
+ })();
248
+ manualOperations.set(promise, controller);
249
+ promise.finally(() => manualOperations.delete(promise)).catch(() => {});
250
+ return promise;
251
+ }
252
+ return { sync, manualOperation, snapshot: () => structuredClone(state),
253
+ reset() { stop(); profiles.setPolicy([]); state = { source: null, revision: null,
254
+ checked_at: null, user_id: null, error: null, running: false, skills: [], plugins: [] }; },
255
+ dispose() { disposed = true; stop(); return Promise.allSettled([inFlight, ...manualOperations.keys()]); },
256
+ };
257
+ }
package/lib/http.js CHANGED
@@ -20,7 +20,8 @@ export class PlatformError extends Error {
20
20
  */
21
21
  export async function platformError(response, fallbackMessage) {
22
22
  const data = await response.json().catch(() => ({}));
23
- return new PlatformError(data?.detail || data?.error?.message || fallbackMessage, {
23
+ const message = data?.detail || data?.error?.message;
24
+ return new PlatformError(typeof message === 'string' && /[\u3400-\u9fff]/.test(message) ? message : fallbackMessage, {
24
25
  status: response.status,
25
26
  code: data?.error?.code || data?.code || null,
26
27
  });
@@ -30,12 +31,14 @@ export async function platformError(response, fallbackMessage) {
30
31
  export async function platformFetch(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
31
32
  let response;
32
33
  try {
33
- response = await fetch(url, { ...options, signal: AbortSignal.timeout(timeoutMs) });
34
+ const timeout = AbortSignal.timeout(timeoutMs);
35
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
36
+ response = await fetch(url, { ...options, signal });
34
37
  } catch (error) {
35
38
  if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
36
39
  throw new PlatformError('连接 AgentOne 平台超时,请稍后重试');
37
40
  }
38
- throw error;
41
+ throw new PlatformError('无法连接 AgentOne 平台,请检查平台地址和网络后重试');
39
42
  }
40
43
  return response;
41
44
  }