dsh-agentone 0.5.38 → 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
@@ -14,8 +14,8 @@ lark-cli / ccpg-cli 状态探测。
14
14
  OpenAI 兼容代理(`/api/plugin/v1`),模型密钥不下发到本机;对话经平台
15
15
  走 TokenHub 按用户计量。平台 403 的 `plan_required` 错误码原样透传,
16
16
  前端据此引导申请套餐(不依赖中文文案匹配)。
17
- - **SkillHub 技能(M3)**:套餐技能自动装齐(启动/登录时,且常驻期间
18
- 套餐生效、套餐新配技能时由前端触发器与手动「同步套餐技能」按钮补装);
17
+ - **SkillHub 技能(M3)**:套餐技能与管理员默认技能在启动/登录和常驻后台同步,
18
+ 支持 `611/c611-ops-workbench`、`611--c611-ops-workbench` 与全局技能名;
19
19
  技能市场搜索、手动安装/卸载,装/卸载后技能列表即时刷新(失效 45s
20
20
  status 缓存);已装技能平台侧对比 latest 版本/指纹,有更新时展示
21
21
  「v旧 → v新」并支持一键升级(覆盖式重装,原子替换旧目录);技能包
@@ -27,11 +27,17 @@ lark-cli / ccpg-cli 状态探测。
27
27
  登出时全部清理。会话内使用:`source $DSH_HOME/agentone/skillhub.env`。
28
28
  - **套餐申请(M3+)**:状态页「套餐」tab 展示当前套餐/审批中/可申请列表,
29
29
  一键提交申请;审批通过后前端轮询检测到 active 自动同步模型。
30
- - **dsh 插件管理(M3+)**:列出镜像内置与用户自装插件(版本/升级/激活态),
31
- 内置插件缺失时启动自动重装(自身也在常驻保证清单内,防误删导致平台集成
32
- 失效);desktop 受管 pnpm 通道操作串行排队,宿主并发锁占用时自动退避
33
- 重试(不把「another desktop pnpm operation is already running」抛给用户);
34
- 自身随平台镜像升级(列表中不提供按钮)。
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
+ 不属于平台默认清单,自身只在独立安装模式允许手动升级。
35
41
  - **CLI 探测、安装与授权(M4)**:`lark-cli` / `ccpg-cli` 安装与授权态三态探测;
36
42
  未安装时「安装」一键自助安装(lark 走 `npm install -g @larksuite/cli@latest`,
37
43
  ccpg 走公司 `auto-install.sh`,口径与平台 cli-deployd 一致);
@@ -107,6 +113,7 @@ dsh plugin --profile web add dsh-agentone
107
113
  | --- | --- | --- |
108
114
  | `platformUrl` | `https://ccpg.one.agentone.work` | AgentOne 平台地址(本地开发用 profile `cordis.patch.yml` 覆盖为 `http://127.0.0.1:8558`) |
109
115
  | `storeDir` | `$DSH_HOME/agentone` | 凭据目录 |
116
+ | `profileName` | 空 | Web 模式填写实际启动 profile(例如 web 或自定义名);无法确定时暂停插件写入。Desktop 自动读取当前 profile |
110
117
 
111
118
  密钥类值一律不进 settings,仅落 `credentials.json`(0600)。
112
119
 
@@ -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
  }