dsh-agentone 0.5.38 → 0.5.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/skill.js CHANGED
@@ -1,132 +1,210 @@
1
- // dsh-agentone 技能同步(M3):平台 /api/plugin/v1/skills 用户级通道。
2
- //
3
- // 平台代理下载(SkillHub token 不出平台),本插件负责:
4
- // 1. listPlatformSkills 拉套餐技能 + 台账安装状态;
5
- // 2. installPlatformSkill 下载 zip → 解包到 $DSH_HOME/skills/<ns--slug>/
6
- // (目录名保留 ns--slug 原样,见平台侧 namespaced-dir 约定)→ 回报
7
- // 版本指纹(X-Skill-* 响应头);
8
- // 3. removePlatformSkill 卸载(平台删台账 + 本机删目录)。
9
- // 解包用 fflate(纯 JS 无原生依赖);zip 条目名做穿越清洗,落盘先写临时
10
- // 目录再原子改名,失败不留半成品。
11
- import { mkdir, rm, writeFile, rename } from 'node:fs/promises';
1
+ // 平台技能本机安装:唯一 staging、旧目录备份、落盘与回报分离。
2
+ import { randomUUID } from 'node:crypto';
3
+ import { lstat, mkdir, rm, writeFile, rename, readFile } from 'node:fs/promises';
12
4
  import { join } from 'node:path';
13
5
  import { unzipSync } from 'fflate';
14
-
15
6
  import { platformError, platformFetch } from './http.js';
16
- import { resolveDshHome } from './store.js';
7
+ import { canonicalSkillSlug, localSkillState, readSkillRecord, skillDigest, skillOwner, skillPaths, writeSkillRecord } from './skill-state.js';
17
8
 
18
- /** 技能包下载超时:zip 体积可达数十 MB,比普通 JSON 调用放宽。 */
19
9
  const DOWNLOAD_TIMEOUT_MS = 60_000;
20
-
21
- function skillsRoot(config) {
22
- return join(resolveDshHome(config), 'skills');
10
+ // 多个 profile 共享同一 skills root,锁绑定物理技能目录而非 profile。
11
+ const operations = new Map();
12
+ function locked(paths, operation) {
13
+ const previous = operations.get(paths.dir) || Promise.resolve();
14
+ const result = previous.catch(() => {}).then(operation);
15
+ const tail = result.catch(() => {}).finally(() => { if (operations.get(paths.dir) === tail) operations.delete(paths.dir); });
16
+ operations.set(paths.dir, tail);
17
+ return result;
23
18
  }
24
-
25
- export function skillDir(config, canonicalSlug) {
26
- return join(skillsRoot(config), canonicalSlug);
19
+ function checkActive(options) {
20
+ options.signal?.throwIfAborted();
21
+ if (options.isCurrent && !options.isCurrent()) throw new Error('登录身份或目标已改变,请重新同步');
27
22
  }
23
+ export function skillDir(config, canonicalSlug) { return skillPaths(config, canonicalSlug).dir; }
28
24
 
29
- export async function listPlatformSkills(platformUrl, accessToken) {
25
+ export async function listPlatformSkills(platformUrl, accessToken, options = {}) {
30
26
  const response = await platformFetch(`${platformUrl.replace(/\/+$/, '')}/api/plugin/v1/skills`, {
31
- headers: { Authorization: `Bearer ${accessToken}` },
27
+ headers: { Authorization: `Bearer ${accessToken}` }, signal: options.signal,
32
28
  });
33
29
  if (!response.ok) throw await platformError(response, '获取平台技能列表失败');
34
30
  return response.json();
35
31
  }
36
-
37
- /** zip 条目名清洗:拒绝绝对路径/上跳/空名,返回安全相对路径。 */
38
32
  function safeEntryName(name) {
39
- const normalized = String(name || '').replace(/\\/g, '/').replace(/^\/+/, '');
40
- if (!normalized || normalized.includes('../') || normalized === '..') return null;
33
+ const normalized = String(name || '').replace(/\\/g, '/');
34
+ if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) return null;
35
+ const parts = normalized.split('/');
36
+ if (parts.some((part) => !part || part === '..' || part === '.')) return null;
41
37
  return normalized;
42
38
  }
43
-
44
- /** 拆技能标识:含 `--` 拆 ns/slug,否则按平台兼容规则归 global 命名空间。 */
45
- export function splitSkillSlug(canonicalSlug) {
39
+ export function splitSkillSlug(value) {
40
+ const canonicalSlug = canonicalSkillSlug(value);
46
41
  const separator = canonicalSlug.indexOf('--');
47
- if (separator > 0) {
48
- return {
49
- namespace: canonicalSlug.slice(0, separator),
50
- slug: canonicalSlug.slice(separator + 2),
51
- };
52
- }
53
- return { namespace: 'global', slug: canonicalSlug };
42
+ return separator > 0
43
+ ? { namespace: canonicalSlug.slice(0, separator), slug: canonicalSlug.slice(separator + 2) }
44
+ : { namespace: 'global', slug: canonicalSlug };
54
45
  }
55
-
56
- export async function installPlatformSkill(platformUrl, accessToken, canonicalSlug, options = {}) {
57
- const base = platformUrl.replace(/\/+$/, '');
46
+ async function report(platformUrl, accessToken, canonicalSlug, record, options) {
47
+ checkActive(options);
58
48
  const { namespace, slug } = splitSkillSlug(canonicalSlug);
59
- if (!slug) throw new Error(`技能标识格式不正确:${canonicalSlug}`);
60
-
61
- const downloadResponse = await platformFetch(
62
- `${base}/api/plugin/v1/skills/${namespace}/${slug}/download`,
63
- { headers: { Authorization: `Bearer ${accessToken}` } },
64
- DOWNLOAD_TIMEOUT_MS,
49
+ const source = ['plan', 'builtin'].includes(record.source) ? `?source=${record.source}` : '';
50
+ const response = await platformFetch(
51
+ `${platformUrl.replace(/\/+$/, '')}/api/plugin/v1/skills/${namespace}/${slug}/installed${source}`,
52
+ { method: 'POST', signal: options.signal,
53
+ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
54
+ body: JSON.stringify({ version: record.version, fingerprint: record.fingerprint }), },
65
55
  );
66
- if (!downloadResponse.ok) {
67
- throw await platformError(downloadResponse, `技能包下载失败(HTTP ${downloadResponse.status})`);
56
+ if (!response.ok) throw await platformError(response, '技能已安装,平台回报失败,请稍后重试');
57
+ }
58
+ async function exists(path) {
59
+ try { return await lstat(path); }
60
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
61
+ }
62
+ // 恢复提交前写入的 journal。新目录完整时补齐记录,否则恢复旧目录。
63
+ async function recover(paths) {
64
+ for (const path of [paths.home, paths.root, paths.dir]) {
65
+ const stat = await exists(path);
66
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error('技能路径包含符号链接或非目录,请检查本机文件');
68
67
  }
69
- const version = downloadResponse.headers.get('x-skill-version') || '';
70
- const fingerprint = downloadResponse.headers.get('x-skill-fingerprint') || '';
71
- if (!version || !fingerprint) throw new Error('技能包响应缺少版本信息');
72
-
73
- const archive = new Uint8Array(await downloadResponse.arrayBuffer());
74
- let entries;
75
- try {
76
- entries = unzipSync(archive);
77
- } catch {
78
- throw new Error('技能包不是有效的 zip 文件');
68
+ const journalPath = `${paths.record}.journal`;
69
+ let journal;
70
+ try { journal = JSON.parse(await readFile(journalPath, 'utf8')); }
71
+ catch (error) { if (error.code === 'ENOENT') return; throw new Error('技能安装恢复记录损坏,请检查本机目录'); }
72
+ if (!/^[0-9a-f-]{36}$/.test(journal.id || '') || !journal.next?.digest) {
73
+ throw new Error('技能安装恢复记录格式异常,请检查本机目录');
74
+ }
75
+ const backup = `${paths.dir}.__backup-${journal.id}`;
76
+ const staging = `${paths.dir}.__staging-${journal.id}`;
77
+ for (const path of [backup, staging]) {
78
+ const stat = await exists(path);
79
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error('技能恢复路径异常,请检查本机文件');
79
80
  }
81
+ if (await exists(paths.dir)) {
82
+ const digest = await skillDigest(paths.dir);
83
+ if (digest === journal.next.digest) await writeSkillRecord(paths, journal.next);
84
+ else if (await exists(backup)) throw new Error('技能恢复遇到目录冲突,请检查本机文件');
85
+ } else if (await exists(backup)) await rename(backup, paths.dir);
86
+ await rm(staging, { recursive: true, force: true });
87
+ await rm(backup, { recursive: true, force: true });
88
+ await rm(journalPath, { force: true });
89
+ }
80
90
 
81
- const root = skillsRoot(options.config || {});
82
- const finalDir = join(root, canonicalSlug);
83
- const stagingDir = `${finalDir}.__staging`;
84
- await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
85
- await mkdir(stagingDir, { recursive: true });
86
- try {
87
- let fileCount = 0;
88
- for (const [name, bytes] of Object.entries(entries)) {
89
- if (name.endsWith('/')) continue;
90
- const safeName = safeEntryName(name);
91
- if (!safeName) throw new Error(`技能包含不安全路径:${name}`);
92
- const target = join(stagingDir, safeName);
93
- await mkdir(join(target, '..'), { recursive: true });
94
- await writeFile(target, bytes);
95
- fileCount += 1;
91
+ export function installPlatformSkill(platformUrl, accessToken, value, options = {}) {
92
+ const canonicalSlug = canonicalSkillSlug(value);
93
+ const paths = skillPaths(options.config || {}, canonicalSlug);
94
+ return locked(paths, async () => {
95
+ checkActive(options);
96
+ await recover(paths);
97
+ const owner = skillOwner(platformUrl, options);
98
+ const initial = options.missingOnly ? await localSkillState(platformUrl, canonicalSlug, options) : null;
99
+ if (initial) {
100
+ if (initial.state === 'conflict') throw new Error('本机技能包含用户文件或来自其他来源,请确认后手动应用');
101
+ const newer = options.updateLatest && options.latestVersion && options.latestFingerprint && initial.record
102
+ && (initial.record.version !== options.latestVersion || initial.record.fingerprint !== options.latestFingerprint);
103
+ if (initial.state === 'current' && !newer) return { canonical_slug: canonicalSlug, version: initial.record.version, state: 'current' };
104
+ if (initial.state === 'report_pending' && !newer) return retryReport(platformUrl, accessToken, canonicalSlug, paths, options);
96
105
  }
97
- if (fileCount === 0) throw new Error('技能包为空');
98
- await rm(finalDir, { recursive: true, force: true }).catch(() => {});
99
- await rename(stagingDir, finalDir);
106
+ const { namespace, slug } = splitSkillSlug(canonicalSlug);
107
+ const downloadResponse = await platformFetch(
108
+ `${platformUrl.replace(/\/+$/, '')}/api/plugin/v1/skills/${namespace}/${slug}/download`,
109
+ { headers: { Authorization: `Bearer ${accessToken}` }, signal: options.signal }, DOWNLOAD_TIMEOUT_MS,
110
+ );
111
+ if (!downloadResponse.ok) throw await platformError(downloadResponse, '技能包下载失败,请稍后重试');
112
+ const version = downloadResponse.headers.get('x-skill-version') || '';
113
+ const fingerprint = downloadResponse.headers.get('x-skill-fingerprint') || '';
114
+ if (!version || !fingerprint) throw new Error('技能包响应缺少版本信息');
115
+ const archive = new Uint8Array(await downloadResponse.arrayBuffer());
116
+ let entries;
117
+ try { entries = unzipSync(archive); } catch { throw new Error('技能包不是有效的 zip 文件'); }
118
+ const id = randomUUID();
119
+ const staging = `${paths.dir}.__staging-${id}`;
120
+ const backup = `${paths.dir}.__backup-${id}`;
121
+ const journal = `${paths.record}.journal`;
122
+ await mkdir(paths.root, { recursive: true });
123
+ const rootStat = await lstat(paths.root);
124
+ if (rootStat.isSymbolicLink()) throw new Error('技能根目录为符号链接,请先检查本机配置');
125
+ await mkdir(staging);
126
+ let movedOld = false;
127
+ let committed = false;
128
+ try {
129
+ let fileCount = 0;
130
+ for (const [name, bytes] of Object.entries(entries)) {
131
+ if (name.endsWith('/')) continue;
132
+ const safeName = safeEntryName(name);
133
+ if (!safeName) throw new Error(`技能包含不安全路径:${name}`);
134
+ const target = join(staging, safeName);
135
+ await mkdir(join(target, '..'), { recursive: true });
136
+ await writeFile(target, bytes); fileCount += 1;
137
+ }
138
+ if (!fileCount) throw new Error('技能包为空');
139
+ let skillEntry = '';
140
+ try { skillEntry = await readFile(join(staging, 'SKILL.md'), 'utf8'); } catch {}
141
+ if (!skillEntry.trim()) throw new Error('技能包缺少有效 SKILL.md');
142
+ const record = { schema_version: 1, ...owner, canonical_slug: canonicalSlug, version, fingerprint,
143
+ digest: await skillDigest(staging), source: options.source || 'manual', report_pending: true,
144
+ installed_at: new Date().toISOString() };
145
+ // 再检查:下载期间可能已注销、更换账号或停用清单。
146
+ await options.beforeCommit?.();
147
+ checkActive(options);
148
+ if (options.missingOnly) {
149
+ const fresh = await localSkillState(platformUrl, canonicalSlug, options);
150
+ if (fresh.state === 'conflict' || (initial.state === 'missing' && fresh.state !== 'missing')
151
+ || (initial.state !== 'missing' && (fresh.state === 'missing' || fresh.record?.digest !== initial.record?.digest
152
+ || fresh.record?.version !== initial.record?.version || fresh.record?.fingerprint !== initial.record?.fingerprint))) {
153
+ throw new Error('本机技能在下载期间已改变,请重新核对后同步');
154
+ }
155
+ }
156
+ await mkdir(join(paths.home, 'agentone', 'skill-state'), { recursive: true, mode: 0o700 });
157
+ await writeFile(journal, JSON.stringify({ id, next: record }) + '\n', { mode: 0o600 });
158
+ if (await exists(paths.dir)) { await rename(paths.dir, backup); movedOld = true; }
159
+ try { await rename(staging, paths.dir); }
160
+ catch (error) { if (movedOld) { await rename(backup, paths.dir); movedOld = false; } throw error; }
161
+ committed = true;
162
+ await writeSkillRecord(paths, record);
163
+ await rm(backup, { recursive: true, force: true });
164
+ await rm(journal, { force: true });
165
+ let error = null;
166
+ try {
167
+ await report(platformUrl, accessToken, canonicalSlug, record, options);
168
+ checkActive(options); record.report_pending = false; await writeSkillRecord(paths, record);
169
+ } catch (failure) { error = failure.message; }
170
+ return { canonical_slug: canonicalSlug, version, files: fileCount,
171
+ state: record.report_pending ? 'report_pending' : 'current', error };
172
+ } catch (error) {
173
+ if (!committed) {
174
+ if (movedOld && !(await exists(paths.dir))) await rename(backup, paths.dir);
175
+ await rm(journal, { force: true });
176
+ }
177
+ throw error;
178
+ } finally { await rm(staging, { recursive: true, force: true }); }
179
+ });
180
+ }
181
+ async function retryReport(platformUrl, accessToken, canonicalSlug, paths, options) {
182
+ const record = await readSkillRecord(paths);
183
+ try {
184
+ await report(platformUrl, accessToken, canonicalSlug, record, options);
185
+ checkActive(options); record.report_pending = false; await writeSkillRecord(paths, record);
186
+ return { canonical_slug: canonicalSlug, version: record.version, state: 'current' };
100
187
  } catch (error) {
101
- await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
102
- throw error;
188
+ return { canonical_slug: canonicalSlug, version: record.version, state: 'report_pending', error: error.message };
103
189
  }
104
-
105
- const confirmParams = options.source === 'plan' ? '?source=plan' : '';
106
- const confirmResponse = await platformFetch(
107
- `${base}/api/plugin/v1/skills/${namespace}/${slug}/installed${confirmParams}`,
108
- {
109
- method: 'POST',
110
- headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
111
- body: JSON.stringify({ version, fingerprint }),
112
- },
113
- );
114
- if (!confirmResponse.ok) {
115
- throw await platformError(confirmResponse, '技能安装回报失败');
116
- }
117
- return { canonical_slug: canonicalSlug, version, files: Object.keys(entries).length };
118
190
  }
119
-
120
- export async function removePlatformSkill(platformUrl, accessToken, canonicalSlug, options = {}) {
121
- const base = platformUrl.replace(/\/+$/, '');
122
- const { namespace, slug } = splitSkillSlug(canonicalSlug);
123
- if (!slug) throw new Error(`技能标识格式不正确:${canonicalSlug}`);
124
- await platformFetch(`${base}/api/plugin/v1/skills/${namespace}/${slug}`, {
125
- method: 'DELETE',
126
- headers: { Authorization: `Bearer ${accessToken}` },
127
- }).catch(() => {});
128
- await rm(skillDir(options.config || {}, canonicalSlug), { recursive: true, force: true }).catch(
129
- () => {},
130
- );
131
- return { canonical_slug: canonicalSlug };
191
+ export function removePlatformSkill(platformUrl, accessToken, value, options = {}) {
192
+ const canonicalSlug = canonicalSkillSlug(value);
193
+ const paths = skillPaths(options.config || {}, canonicalSlug);
194
+ return locked(paths, async () => {
195
+ checkActive(options); await recover(paths);
196
+ await options.beforeCommit?.();
197
+ checkActive(options);
198
+ const local = await localSkillState(platformUrl, canonicalSlug, options);
199
+ if (local.state === 'conflict') throw new Error('技能包含用户文件或属于其他账号,请先检查本机目录');
200
+ const { namespace, slug } = splitSkillSlug(canonicalSlug);
201
+ await platformFetch(`${platformUrl.replace(/\/+$/, '')}/api/plugin/v1/skills/${namespace}/${slug}`, {
202
+ method: 'DELETE', signal: options.signal, headers: { Authorization: `Bearer ${accessToken}` },
203
+ }).catch(() => {});
204
+ await options.beforeCommit?.();
205
+ checkActive(options);
206
+ await rm(paths.dir, { recursive: true, force: true });
207
+ await rm(paths.record, { force: true });
208
+ return { canonical_slug: canonicalSlug };
209
+ });
132
210
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agentone",
3
- "version": "0.5.38",
4
- "description": "AgentOne 平台集成:IDOne 登录(飞书兜底)、平台模型、SkillHub 技能、套餐与 CLI;lark-cli 首装一键初始化飞书应用;平台托管实例免登录自动授权",
3
+ "version": "0.5.40",
4
+ "description": "AgentOne 平台集成:平台模型、SkillHub 技能、套餐与 CLI;lark-cli 首装一键初始化飞书应用;修复 DeepSeek HARNESS 桌面壳登录回调地址不合法",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "smoke": "node scripts/env-matrix-smoke.mjs"