@js-eyes/protocol 2.8.1 → 2.8.2
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/extra-integrity.js +199 -199
- package/fs-io.js +37 -37
- package/index.js +269 -269
- package/openclaw-paths.js +33 -33
- package/package.json +38 -38
- package/registry-client.js +50 -50
- package/safe-npm.js +158 -158
- package/skill-registry.js +745 -745
- package/skill-runner.js +48 -48
- package/skills.js +758 -758
- package/zip-extract.js +208 -208
package/skills.js
CHANGED
|
@@ -1,758 +1,758 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const crypto = require('crypto');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const os = require('os');
|
|
6
|
-
const path = require('path');
|
|
7
|
-
const { extractZipBuffer } = require('./zip-extract');
|
|
8
|
-
const { ensureDir, readJson, safeStat } = require('./fs-io');
|
|
9
|
-
const { getOpenClawConfigPath } = require('./openclaw-paths');
|
|
10
|
-
const safeNpm = require('./safe-npm');
|
|
11
|
-
|
|
12
|
-
const SKILL_CONTRACT_FILE = 'skill.contract.js';
|
|
13
|
-
const INTEGRITY_FILE = '.integrity.json';
|
|
14
|
-
const INSTALL_MANIFEST_FILE = 'skills-install.json';
|
|
15
|
-
|
|
16
|
-
function toolNameToActionSegment(name) {
|
|
17
|
-
return String(name || '')
|
|
18
|
-
.trim()
|
|
19
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
20
|
-
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
21
|
-
.replace(/^-+|-+$/g, '')
|
|
22
|
-
.toLowerCase();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function skillToolActionName(skillId, toolName) {
|
|
26
|
-
const action = toolNameToActionSegment(toolName);
|
|
27
|
-
return `skill/${skillId}/${action || 'run'}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function loadSkillContract(skillDir) {
|
|
31
|
-
const contractPath = path.resolve(skillDir, SKILL_CONTRACT_FILE);
|
|
32
|
-
if (!fs.existsSync(contractPath)) return null;
|
|
33
|
-
delete require.cache[require.resolve(contractPath)];
|
|
34
|
-
return require(contractPath);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function hasSkillContract(skillDir) {
|
|
38
|
-
return fs.existsSync(path.join(skillDir, SKILL_CONTRACT_FILE));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* 列出某个目录下被视为「候选 skill 目录」的直接子项绝对路径。
|
|
43
|
-
*
|
|
44
|
-
* 与裸 readdirSync 相比多做两件事:
|
|
45
|
-
* 1. 把 symlink-to-directory 也视为目录(Dirent.isDirectory() 对 symlink 为 false)。
|
|
46
|
-
* 2. 对于不存在 / 不可读的目录,返回空数组而不是抛错。
|
|
47
|
-
*
|
|
48
|
-
* 只扫 1 层,不递归。
|
|
49
|
-
*/
|
|
50
|
-
function listSkillDirectories(dir) {
|
|
51
|
-
if (!dir || !fs.existsSync(dir)) return [];
|
|
52
|
-
let entries;
|
|
53
|
-
try {
|
|
54
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
55
|
-
} catch (_) {
|
|
56
|
-
return [];
|
|
57
|
-
}
|
|
58
|
-
const results = [];
|
|
59
|
-
for (const entry of entries) {
|
|
60
|
-
const full = path.join(dir, entry.name);
|
|
61
|
-
if (entry.isDirectory()) {
|
|
62
|
-
results.push(full);
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
if (entry.isSymbolicLink()) {
|
|
66
|
-
const stat = safeStat(full);
|
|
67
|
-
if (stat && stat.isDirectory()) results.push(full);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return results;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function resolveSkillsDir(paths, config = {}) {
|
|
74
|
-
if (config.skillsDir) {
|
|
75
|
-
return path.resolve(config.skillsDir);
|
|
76
|
-
}
|
|
77
|
-
if (paths && paths.skillsDir) {
|
|
78
|
-
return paths.skillsDir;
|
|
79
|
-
}
|
|
80
|
-
if (paths && paths.baseDir) {
|
|
81
|
-
return path.join(paths.baseDir, 'skills');
|
|
82
|
-
}
|
|
83
|
-
return path.resolve('skills');
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* 归一化多源配置:primary + extras。
|
|
88
|
-
*
|
|
89
|
-
* 入参:
|
|
90
|
-
* - { primary, extras }:primary 是单个目录字符串;extras 是字符串数组或 undefined。
|
|
91
|
-
* - 或 { paths, config }:会自动走 resolveSkillsDir 推导 primary,并读 config.extraSkillDirs。
|
|
92
|
-
*
|
|
93
|
-
* 出参:
|
|
94
|
-
* {
|
|
95
|
-
* primary: '/abs/primary',
|
|
96
|
-
* extras: [{ path: '/abs/x', kind: 'skill' | 'dir' }, ...],
|
|
97
|
-
* invalid: [{ path, reason }] // 不存在或读不到的 extra 条目
|
|
98
|
-
* }
|
|
99
|
-
*
|
|
100
|
-
* kind 判定:自身含 skill.contract.js => 'skill';否则当作父目录 'dir'。
|
|
101
|
-
* 对重复路径做去重(primary 自己也不会出现在 extras 里)。
|
|
102
|
-
*/
|
|
103
|
-
function resolveSkillSources(input = {}) {
|
|
104
|
-
let primary = input.primary;
|
|
105
|
-
let extrasInput = input.extras;
|
|
106
|
-
|
|
107
|
-
if (!primary) {
|
|
108
|
-
primary = resolveSkillsDir(input.paths, input.config || {});
|
|
109
|
-
}
|
|
110
|
-
primary = primary ? path.resolve(primary) : '';
|
|
111
|
-
|
|
112
|
-
if (!extrasInput && input.config && Array.isArray(input.config.extraSkillDirs)) {
|
|
113
|
-
extrasInput = input.config.extraSkillDirs;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const rawExtras = Array.isArray(extrasInput) ? extrasInput : [];
|
|
117
|
-
const seen = new Set();
|
|
118
|
-
if (primary) seen.add(primary);
|
|
119
|
-
|
|
120
|
-
const extras = [];
|
|
121
|
-
const invalid = [];
|
|
122
|
-
for (const raw of rawExtras) {
|
|
123
|
-
if (typeof raw !== 'string' || !raw.trim()) {
|
|
124
|
-
invalid.push({ path: String(raw), reason: 'invalid-type' });
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
const abs = path.resolve(raw);
|
|
128
|
-
if (seen.has(abs)) continue;
|
|
129
|
-
seen.add(abs);
|
|
130
|
-
|
|
131
|
-
if (!fs.existsSync(abs)) {
|
|
132
|
-
invalid.push({ path: abs, reason: 'not-found' });
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
const stat = safeStat(abs);
|
|
136
|
-
if (!stat || !stat.isDirectory()) {
|
|
137
|
-
invalid.push({ path: abs, reason: 'not-a-directory' });
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
const kind = hasSkillContract(abs) ? 'skill' : 'dir';
|
|
141
|
-
extras.push({ path: abs, kind });
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return { primary, extras, invalid };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function normalizeSkillMetadata(skillDir) {
|
|
148
|
-
const contract = loadSkillContract(skillDir);
|
|
149
|
-
const pkg = readJson(path.join(skillDir, 'package.json')) || {};
|
|
150
|
-
const cli = contract && contract.cli ? contract.cli : {};
|
|
151
|
-
const openclaw = contract && contract.openclaw ? contract.openclaw : {};
|
|
152
|
-
const tools = Array.isArray(openclaw.tools)
|
|
153
|
-
? openclaw.tools.map((tool) => tool.name)
|
|
154
|
-
: [];
|
|
155
|
-
const commands = Array.isArray(cli.commands)
|
|
156
|
-
? cli.commands.map((command) => command.name)
|
|
157
|
-
: [];
|
|
158
|
-
|
|
159
|
-
const id = contract?.id || pkg.name || path.basename(skillDir);
|
|
160
|
-
|
|
161
|
-
return {
|
|
162
|
-
id,
|
|
163
|
-
name: contract?.name || pkg.name || path.basename(skillDir),
|
|
164
|
-
version: contract?.version || pkg.version || '1.0.0',
|
|
165
|
-
description: contract?.description || pkg.description || '',
|
|
166
|
-
skillDir,
|
|
167
|
-
cliEntry: cli.entry ? path.resolve(skillDir, cli.entry) : path.join(skillDir, 'index.js'),
|
|
168
|
-
commands,
|
|
169
|
-
tools,
|
|
170
|
-
actions: tools.map((tool) => skillToolActionName(id, tool)),
|
|
171
|
-
runtime: contract?.runtime || {},
|
|
172
|
-
contract,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function discoverLocalSkills(skillsDir) {
|
|
177
|
-
return listSkillDirectories(skillsDir)
|
|
178
|
-
.filter((skillDir) => hasSkillContract(skillDir))
|
|
179
|
-
.map((skillDir) => normalizeSkillMetadata(skillDir))
|
|
180
|
-
.filter((skill) => skill && skill.id);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function readSkillById(skillsDir, skillId) {
|
|
184
|
-
const skillDir = path.join(skillsDir, skillId);
|
|
185
|
-
if (!fs.existsSync(skillDir) || !hasSkillContract(skillDir)) return null;
|
|
186
|
-
return normalizeSkillMetadata(skillDir);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* 按 sources 发现所有可用 skill,返回带 source / sourcePath 注解的统一列表。
|
|
191
|
-
*
|
|
192
|
-
* sources 由 resolveSkillSources() 生成;也接受简化形态 { primary, extras: [string] }
|
|
193
|
-
* (会内部再跑一遍 resolveSkillSources 归一化)。
|
|
194
|
-
*
|
|
195
|
-
* 冲突策略:同 id 多源命中时 primary 优先;后续 extras 里的同 id 被跳过。
|
|
196
|
-
* 每次跳过会回调 onConflict({ id, winner, loser }),调用方自行决定是否打日志。
|
|
197
|
-
*
|
|
198
|
-
* 返回值:
|
|
199
|
-
* {
|
|
200
|
-
* skills: [{ ...normalizeSkillMetadata, source: 'primary'|'extra', sourcePath }],
|
|
201
|
-
* conflicts: [{ id, winner: { source, path }, loser: { source, path } }],
|
|
202
|
-
* invalid: sources.invalid // 透传,便于 CLI 展示
|
|
203
|
-
* }
|
|
204
|
-
*/
|
|
205
|
-
function discoverSkillsFromSources(sources, options = {}) {
|
|
206
|
-
const normalized = sources && Array.isArray(sources.extras) && sources.extras.every((e) => e && typeof e === 'object')
|
|
207
|
-
? sources
|
|
208
|
-
: resolveSkillSources(sources || {});
|
|
209
|
-
|
|
210
|
-
const { primary, extras, invalid = [] } = normalized;
|
|
211
|
-
const { onConflict } = options;
|
|
212
|
-
|
|
213
|
-
const byId = new Map();
|
|
214
|
-
const conflicts = [];
|
|
215
|
-
|
|
216
|
-
const register = (skill, source, sourcePath) => {
|
|
217
|
-
if (!skill || !skill.id) return;
|
|
218
|
-
if (byId.has(skill.id)) {
|
|
219
|
-
const existing = byId.get(skill.id);
|
|
220
|
-
const conflict = {
|
|
221
|
-
id: skill.id,
|
|
222
|
-
winner: { source: existing.source, path: existing.sourcePath },
|
|
223
|
-
loser: { source, path: sourcePath },
|
|
224
|
-
};
|
|
225
|
-
conflicts.push(conflict);
|
|
226
|
-
if (typeof onConflict === 'function') {
|
|
227
|
-
try { onConflict(conflict); } catch (_) { /* noop */ }
|
|
228
|
-
}
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
byId.set(skill.id, { ...skill, source, sourcePath });
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
if (primary) {
|
|
235
|
-
for (const skill of discoverLocalSkills(primary)) {
|
|
236
|
-
register(skill, 'primary', primary);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
for (const extra of extras) {
|
|
241
|
-
if (extra.kind === 'skill') {
|
|
242
|
-
if (!hasSkillContract(extra.path)) continue;
|
|
243
|
-
const meta = normalizeSkillMetadata(extra.path);
|
|
244
|
-
if (meta && meta.id) register(meta, 'extra', extra.path);
|
|
245
|
-
continue;
|
|
246
|
-
}
|
|
247
|
-
for (const skill of discoverLocalSkills(extra.path)) {
|
|
248
|
-
register(skill, 'extra', extra.path);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return {
|
|
253
|
-
skills: Array.from(byId.values()),
|
|
254
|
-
conflicts,
|
|
255
|
-
invalid,
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* 在 primary + extras 的联合范围内按 id 找 skill。
|
|
261
|
-
* primary 优先;extras 按传入顺序其次。命中返回带 source / sourcePath 的对象,未命中返回 null。
|
|
262
|
-
*/
|
|
263
|
-
function readSkillByIdFromSources(input = {}) {
|
|
264
|
-
const { id } = input;
|
|
265
|
-
if (!id) return null;
|
|
266
|
-
const { primary, extras } = Array.isArray(input.extras) && input.extras.every((e) => e && typeof e === 'object')
|
|
267
|
-
? input
|
|
268
|
-
: resolveSkillSources(input);
|
|
269
|
-
|
|
270
|
-
if (primary) {
|
|
271
|
-
const hit = readSkillById(primary, id);
|
|
272
|
-
if (hit) return { ...hit, source: 'primary', sourcePath: primary };
|
|
273
|
-
}
|
|
274
|
-
for (const extra of extras) {
|
|
275
|
-
if (extra.kind === 'skill') {
|
|
276
|
-
if (!hasSkillContract(extra.path)) continue;
|
|
277
|
-
const meta = normalizeSkillMetadata(extra.path);
|
|
278
|
-
if (meta && meta.id === id) return { ...meta, source: 'extra', sourcePath: extra.path };
|
|
279
|
-
continue;
|
|
280
|
-
}
|
|
281
|
-
const hit = readSkillById(extra.path, id);
|
|
282
|
-
if (hit) return { ...hit, source: 'extra', sourcePath: extra.path };
|
|
283
|
-
}
|
|
284
|
-
return null;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Registry network I/O lives in registry-client.js so `fetch(…)` is not
|
|
288
|
-
// co-located with `fs.readFileSync(…)` / `fs.createReadStream(…)` in this
|
|
289
|
-
// module. Re-exported below for backwards compatibility. See
|
|
290
|
-
// SECURITY_SCAN_NOTES.md.
|
|
291
|
-
const { fetchSkillsRegistry, downloadBuffer } = require('./registry-client');
|
|
292
|
-
|
|
293
|
-
function resolveOpenClawPluginEntry(definition) {
|
|
294
|
-
try {
|
|
295
|
-
const sdk = require('openclaw/plugin-sdk/plugin-entry');
|
|
296
|
-
if (typeof sdk.definePluginEntry === 'function') {
|
|
297
|
-
return sdk.definePluginEntry(definition);
|
|
298
|
-
}
|
|
299
|
-
} catch {
|
|
300
|
-
// Fallback for local development without the OpenClaw SDK package installed.
|
|
301
|
-
}
|
|
302
|
-
return definition.register;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function getSkillsState(config = {}) {
|
|
306
|
-
const state = config && typeof config === 'object' ? config.skillsEnabled : null;
|
|
307
|
-
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
|
308
|
-
return {};
|
|
309
|
-
}
|
|
310
|
-
return state;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function getLegacyOpenClawSkillState(options = {}) {
|
|
314
|
-
const {
|
|
315
|
-
openclawConfigPath = getOpenClawConfigPath(options),
|
|
316
|
-
skillIds = null,
|
|
317
|
-
} = options;
|
|
318
|
-
|
|
319
|
-
if (!fs.existsSync(openclawConfigPath)) {
|
|
320
|
-
return {};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
let config = null;
|
|
324
|
-
try {
|
|
325
|
-
config = JSON.parse(fs.readFileSync(openclawConfigPath, 'utf8'));
|
|
326
|
-
} catch {
|
|
327
|
-
return {};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const entries = config?.plugins?.entries;
|
|
331
|
-
if (!entries || typeof entries !== 'object') {
|
|
332
|
-
return {};
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const allowedSkillIds = Array.isArray(skillIds) && skillIds.length > 0
|
|
336
|
-
? new Set(skillIds)
|
|
337
|
-
: null;
|
|
338
|
-
const state = {};
|
|
339
|
-
|
|
340
|
-
for (const [skillId, entry] of Object.entries(entries)) {
|
|
341
|
-
if (skillId === 'js-eyes') {
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
if (allowedSkillIds && !allowedSkillIds.has(skillId)) {
|
|
345
|
-
continue;
|
|
346
|
-
}
|
|
347
|
-
if (!entry || typeof entry !== 'object' || entry.enabled === undefined) {
|
|
348
|
-
continue;
|
|
349
|
-
}
|
|
350
|
-
state[skillId] = entry.enabled !== false;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
return state;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function isSkillEnabled(config = {}, skillId, legacyState = {}) {
|
|
357
|
-
const state = getSkillsState(config);
|
|
358
|
-
if (Object.prototype.hasOwnProperty.call(state, skillId)) {
|
|
359
|
-
return state[skillId] !== false;
|
|
360
|
-
}
|
|
361
|
-
if (legacyState && Object.prototype.hasOwnProperty.call(legacyState, skillId)) {
|
|
362
|
-
return legacyState[skillId] !== false;
|
|
363
|
-
}
|
|
364
|
-
return false;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* 从 adapter.tools 构建 OpenClaw 工具定义列表(不执行 api.registerTool)。
|
|
369
|
-
*
|
|
370
|
-
* 用于 SkillRegistry 等需要"先收集再统一注册"的场景;registerOpenClawTools
|
|
371
|
-
* 的注册循环基于此函数的输出。
|
|
372
|
-
*
|
|
373
|
-
* 返回:
|
|
374
|
-
* {
|
|
375
|
-
* toolDefs: [{ definition, optional, toolName }], // 通过过滤的工具
|
|
376
|
-
* summary: { registered: [], skipped: [{ name, reason }], failed: [] },
|
|
377
|
-
* }
|
|
378
|
-
*
|
|
379
|
-
* 注意:本函数不会写入 registeredNames;由调用方在成功注册后维护。
|
|
380
|
-
*/
|
|
381
|
-
function buildAdapterTools(adapter, options = {}) {
|
|
382
|
-
const logger = options.logger || console;
|
|
383
|
-
const sourceName = options.sourceName || adapter?.id || 'js-eyes-skill';
|
|
384
|
-
const declaredTools = Array.isArray(options.declaredTools) && options.declaredTools.length > 0
|
|
385
|
-
? new Set(options.declaredTools)
|
|
386
|
-
: null;
|
|
387
|
-
const registeredNames = options.registeredNames || null;
|
|
388
|
-
const wrapTool = typeof options.wrapTool === 'function' ? options.wrapTool : null;
|
|
389
|
-
|
|
390
|
-
const toolDefs = [];
|
|
391
|
-
const summary = {
|
|
392
|
-
registered: [],
|
|
393
|
-
skipped: [],
|
|
394
|
-
failed: [],
|
|
395
|
-
};
|
|
396
|
-
// Track names we've already emitted in this batch so intra-batch duplicates
|
|
397
|
-
// are rejected even when registeredNames is not mutated here.
|
|
398
|
-
const localSeen = new Set();
|
|
399
|
-
|
|
400
|
-
for (const tool of (adapter && adapter.tools) || []) {
|
|
401
|
-
if (!tool || !tool.name) {
|
|
402
|
-
summary.skipped.push({ name: '(anonymous)', reason: 'missing-name' });
|
|
403
|
-
logger.warn(`[js-eyes] Skipping tool with missing name from ${sourceName}`);
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
if (declaredTools && !declaredTools.has(tool.name)) {
|
|
407
|
-
summary.skipped.push({ name: tool.name, reason: 'undeclared' });
|
|
408
|
-
logger.warn(`[js-eyes] Skipping undeclared tool "${tool.name}" from ${sourceName}`);
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
if ((registeredNames && registeredNames.has(tool.name)) || localSeen.has(tool.name)) {
|
|
412
|
-
summary.skipped.push({ name: tool.name, reason: 'duplicate-name' });
|
|
413
|
-
logger.warn(`[js-eyes] Skipping duplicate tool "${tool.name}" from ${sourceName}`);
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
localSeen.add(tool.name);
|
|
417
|
-
|
|
418
|
-
const definition = {
|
|
419
|
-
name: tool.name,
|
|
420
|
-
label: tool.label,
|
|
421
|
-
description: tool.description,
|
|
422
|
-
parameters: tool.parameters,
|
|
423
|
-
execute: tool.execute,
|
|
424
|
-
};
|
|
425
|
-
const wrapped = wrapTool ? wrapTool(definition, { source: sourceName }) : definition;
|
|
426
|
-
|
|
427
|
-
toolDefs.push({
|
|
428
|
-
toolName: tool.name,
|
|
429
|
-
definition: wrapped,
|
|
430
|
-
optional: Boolean(tool.optional),
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
return { toolDefs, summary };
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function registerOpenClawTools(api, adapter, options = {}) {
|
|
438
|
-
const logger = options.logger || api.logger || console;
|
|
439
|
-
const registeredNames = options.registeredNames || null;
|
|
440
|
-
const sourceName = options.sourceName || adapter?.id || 'js-eyes-skill';
|
|
441
|
-
|
|
442
|
-
const { toolDefs, summary } = buildAdapterTools(adapter, {
|
|
443
|
-
logger,
|
|
444
|
-
sourceName,
|
|
445
|
-
declaredTools: options.declaredTools,
|
|
446
|
-
registeredNames,
|
|
447
|
-
wrapTool: options.wrapTool,
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
for (const entry of toolDefs) {
|
|
451
|
-
try {
|
|
452
|
-
api.registerTool(entry.definition, entry.optional ? { optional: true } : undefined);
|
|
453
|
-
if (registeredNames) {
|
|
454
|
-
registeredNames.add(entry.toolName);
|
|
455
|
-
}
|
|
456
|
-
summary.registered.push(entry.toolName);
|
|
457
|
-
} catch (error) {
|
|
458
|
-
summary.failed.push({ name: entry.toolName, reason: error.message });
|
|
459
|
-
logger.warn(`[js-eyes] Failed to register tool "${entry.toolName}" from ${sourceName}: ${error.message}`);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
return summary;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
function sha256(buffer) {
|
|
467
|
-
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function isMainRefUrl(url) {
|
|
471
|
-
if (typeof url !== 'string') return false;
|
|
472
|
-
return /\/(refs\/heads\/)?main(?=[/?])/.test(url);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
const detectPackageManager = safeNpm.detectPackageManager;
|
|
476
|
-
|
|
477
|
-
function installSkillDependencies(targetDir, options = {}) {
|
|
478
|
-
return safeNpm.installSkillDependencies(targetDir, options);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function listFilesRecursive(dir) {
|
|
482
|
-
const out = [];
|
|
483
|
-
function walk(current) {
|
|
484
|
-
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
485
|
-
for (const entry of entries) {
|
|
486
|
-
const full = path.join(current, entry.name);
|
|
487
|
-
const rel = path.relative(dir, full);
|
|
488
|
-
if (rel === INTEGRITY_FILE || rel === INSTALL_MANIFEST_FILE) continue;
|
|
489
|
-
if (rel.split(path.sep)[0] === 'node_modules') continue;
|
|
490
|
-
if (entry.isDirectory()) {
|
|
491
|
-
walk(full);
|
|
492
|
-
} else if (entry.isFile()) {
|
|
493
|
-
out.push(rel.split(path.sep).join('/'));
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
walk(dir);
|
|
498
|
-
return out.sort();
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function writeIntegrityManifest(targetDir, payload = {}) {
|
|
502
|
-
const files = {};
|
|
503
|
-
for (const rel of listFilesRecursive(targetDir)) {
|
|
504
|
-
const full = path.join(targetDir, rel);
|
|
505
|
-
files[rel] = sha256(fs.readFileSync(full));
|
|
506
|
-
}
|
|
507
|
-
const manifest = {
|
|
508
|
-
version: 1,
|
|
509
|
-
createdAt: new Date().toISOString(),
|
|
510
|
-
skillId: payload.skillId || null,
|
|
511
|
-
sourceUrl: payload.sourceUrl || null,
|
|
512
|
-
bundleSha256: payload.bundleSha256 || null,
|
|
513
|
-
declaredTools: payload.declaredTools || [],
|
|
514
|
-
files,
|
|
515
|
-
};
|
|
516
|
-
const filePath = path.join(targetDir, INTEGRITY_FILE);
|
|
517
|
-
fs.writeFileSync(filePath, JSON.stringify(manifest, null, 2) + '\n');
|
|
518
|
-
try { fs.chmodSync(filePath, 0o600); } catch {}
|
|
519
|
-
return manifest;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
function readSkillIntegrity(skillDir) {
|
|
523
|
-
const filePath = path.join(skillDir, INTEGRITY_FILE);
|
|
524
|
-
if (!fs.existsSync(filePath)) return null;
|
|
525
|
-
try {
|
|
526
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
527
|
-
} catch {
|
|
528
|
-
return null;
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
function verifySkillIntegrity(skillDir) {
|
|
533
|
-
const manifest = readSkillIntegrity(skillDir);
|
|
534
|
-
if (!manifest || !manifest.files) {
|
|
535
|
-
return { hasIntegrity: false, ok: false, mismatches: [], missing: [], extra: [], checked: 0 };
|
|
536
|
-
}
|
|
537
|
-
const expected = manifest.files;
|
|
538
|
-
const expectedKeys = Object.keys(expected);
|
|
539
|
-
const present = new Set(listFilesRecursive(skillDir));
|
|
540
|
-
|
|
541
|
-
const mismatches = [];
|
|
542
|
-
const missing = [];
|
|
543
|
-
|
|
544
|
-
for (const rel of expectedKeys) {
|
|
545
|
-
const full = path.join(skillDir, rel);
|
|
546
|
-
if (!fs.existsSync(full)) {
|
|
547
|
-
missing.push(rel);
|
|
548
|
-
continue;
|
|
549
|
-
}
|
|
550
|
-
const actual = sha256(fs.readFileSync(full));
|
|
551
|
-
if (actual !== expected[rel]) {
|
|
552
|
-
mismatches.push(rel);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
const extra = [...present].filter((rel) => !Object.prototype.hasOwnProperty.call(expected, rel));
|
|
557
|
-
|
|
558
|
-
return {
|
|
559
|
-
hasIntegrity: true,
|
|
560
|
-
ok: mismatches.length === 0 && missing.length === 0,
|
|
561
|
-
mismatches,
|
|
562
|
-
missing,
|
|
563
|
-
extra,
|
|
564
|
-
checked: expectedKeys.length,
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
async function planSkillInstall(options) {
|
|
569
|
-
const {
|
|
570
|
-
skillId,
|
|
571
|
-
registryUrl,
|
|
572
|
-
skillsDir,
|
|
573
|
-
stagingDir = path.join(os.tmpdir(), `js-eyes-skill-staging-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`),
|
|
574
|
-
force = false,
|
|
575
|
-
logger = console,
|
|
576
|
-
} = options;
|
|
577
|
-
|
|
578
|
-
ensureDir(skillsDir);
|
|
579
|
-
const registry = await fetchSkillsRegistry(registryUrl);
|
|
580
|
-
const skill = registry.skills?.find((entry) => entry.id === skillId);
|
|
581
|
-
if (!skill) {
|
|
582
|
-
const ids = (registry.skills || []).map((entry) => entry.id).join(', ');
|
|
583
|
-
throw new Error(`技能 "${skillId}" 未在注册表中找到。可用技能: ${ids || '无'}`);
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
const targetDir = path.join(skillsDir, skillId);
|
|
587
|
-
if (fs.existsSync(targetDir) && !force) {
|
|
588
|
-
throw new Error(`技能 "${skillId}" 已安装在 ${targetDir}(使用 force=true 覆盖)`);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
if (!skill.sha256 || typeof skill.sha256 !== 'string') {
|
|
592
|
-
throw new Error(`技能 "${skillId}" 注册表条目缺少 sha256 校验和,拒绝下载`);
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
const candidateUrls = [skill.downloadUrl];
|
|
596
|
-
if (skill.downloadUrlFallback) {
|
|
597
|
-
if (isMainRefUrl(skill.downloadUrlFallback)) {
|
|
598
|
-
logger?.warn?.(`[js-eyes] Ignoring @main fallback URL for ${skillId} (use a tagged URL)`);
|
|
599
|
-
} else {
|
|
600
|
-
candidateUrls.push(skill.downloadUrlFallback);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
const { buffer, url } = await downloadBuffer(candidateUrls.filter(Boolean), logger);
|
|
605
|
-
const digest = sha256(buffer);
|
|
606
|
-
if (digest !== skill.sha256.toLowerCase()) {
|
|
607
|
-
throw new Error(`技能 "${skillId}" 哈希不符: expected ${skill.sha256}, got ${digest}`);
|
|
608
|
-
}
|
|
609
|
-
if (typeof skill.size === 'number' && buffer.length !== skill.size) {
|
|
610
|
-
throw new Error(`技能 "${skillId}" 大小不符: expected ${skill.size}, got ${buffer.length}`);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
ensureDir(stagingDir);
|
|
614
|
-
if (fs.existsSync(stagingDir) && fs.readdirSync(stagingDir).length > 0) {
|
|
615
|
-
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
616
|
-
ensureDir(stagingDir);
|
|
617
|
-
}
|
|
618
|
-
extractZipBuffer(buffer, stagingDir);
|
|
619
|
-
|
|
620
|
-
const stagedFiles = listFilesRecursive(stagingDir);
|
|
621
|
-
const declaredTools = Array.isArray(skill.tools) ? skill.tools.slice() : [];
|
|
622
|
-
|
|
623
|
-
return {
|
|
624
|
-
plan: {
|
|
625
|
-
skillId,
|
|
626
|
-
registryUrl,
|
|
627
|
-
sourceUrl: url,
|
|
628
|
-
bundleSha256: digest,
|
|
629
|
-
bundleSize: buffer.length,
|
|
630
|
-
targetDir,
|
|
631
|
-
stagingDir,
|
|
632
|
-
declaredTools,
|
|
633
|
-
stagedFiles,
|
|
634
|
-
registryEntry: skill,
|
|
635
|
-
hasLockfile: fs.existsSync(path.join(stagingDir, 'package-lock.json')),
|
|
636
|
-
hasPackageJson: fs.existsSync(path.join(stagingDir, 'package.json')),
|
|
637
|
-
},
|
|
638
|
-
skill,
|
|
639
|
-
registry,
|
|
640
|
-
};
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
function cleanupStaging(stagingDir) {
|
|
644
|
-
if (stagingDir && fs.existsSync(stagingDir)) {
|
|
645
|
-
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
function applySkillInstall(plan, options = {}) {
|
|
650
|
-
if (!plan || !plan.stagingDir || !plan.targetDir) {
|
|
651
|
-
throw new Error('applySkillInstall: invalid plan');
|
|
652
|
-
}
|
|
653
|
-
const requireLockfile = options.requireLockfile !== false;
|
|
654
|
-
if (plan.hasPackageJson && requireLockfile && !plan.hasLockfile) {
|
|
655
|
-
cleanupStaging(plan.stagingDir);
|
|
656
|
-
throw new Error('技能包缺少 package-lock.json,拒绝安装(设置 security.requireLockfile=false 可放宽)');
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
installSkillDependencies(plan.stagingDir, {
|
|
660
|
-
requireLockfile,
|
|
661
|
-
allowPostinstall: Boolean(options.allowPostinstall),
|
|
662
|
-
});
|
|
663
|
-
|
|
664
|
-
if (fs.existsSync(plan.targetDir)) {
|
|
665
|
-
fs.rmSync(plan.targetDir, { recursive: true, force: true });
|
|
666
|
-
}
|
|
667
|
-
ensureDir(path.dirname(plan.targetDir));
|
|
668
|
-
fs.renameSync(plan.stagingDir, plan.targetDir);
|
|
669
|
-
|
|
670
|
-
const integrity = writeIntegrityManifest(plan.targetDir, {
|
|
671
|
-
skillId: plan.skillId,
|
|
672
|
-
sourceUrl: plan.sourceUrl,
|
|
673
|
-
bundleSha256: plan.bundleSha256,
|
|
674
|
-
declaredTools: plan.declaredTools,
|
|
675
|
-
});
|
|
676
|
-
|
|
677
|
-
const installManifest = {
|
|
678
|
-
skillId: plan.skillId,
|
|
679
|
-
sourceUrl: plan.sourceUrl,
|
|
680
|
-
bundleSha256: plan.bundleSha256,
|
|
681
|
-
bundleSize: plan.bundleSize,
|
|
682
|
-
installedAt: integrity.createdAt,
|
|
683
|
-
declaredTools: plan.declaredTools,
|
|
684
|
-
};
|
|
685
|
-
const installManifestPath = path.join(plan.targetDir, INSTALL_MANIFEST_FILE);
|
|
686
|
-
fs.writeFileSync(installManifestPath, JSON.stringify(installManifest, null, 2) + '\n');
|
|
687
|
-
try { fs.chmodSync(installManifestPath, 0o600); } catch {}
|
|
688
|
-
|
|
689
|
-
return {
|
|
690
|
-
targetDir: plan.targetDir,
|
|
691
|
-
integrity,
|
|
692
|
-
installManifest,
|
|
693
|
-
};
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
async function installSkillFromRegistry(options) {
|
|
697
|
-
const { plan, skill, registry } = await planSkillInstall(options);
|
|
698
|
-
const apply = applySkillInstall(plan, {
|
|
699
|
-
requireLockfile: options.requireLockfile,
|
|
700
|
-
allowPostinstall: options.allowPostinstall,
|
|
701
|
-
});
|
|
702
|
-
return {
|
|
703
|
-
registry,
|
|
704
|
-
skill,
|
|
705
|
-
targetDir: apply.targetDir,
|
|
706
|
-
integrity: apply.integrity,
|
|
707
|
-
installManifest: apply.installManifest,
|
|
708
|
-
plan,
|
|
709
|
-
};
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// `runSkillCli` is the only remaining child_process caller in @js-eyes/protocol
|
|
713
|
-
// outside the hardened `safe-npm.js` module. It lives in its own file so the
|
|
714
|
-
// child_process import is not co-located with `fetch(registryUrl)` / other
|
|
715
|
-
// network code in this module. See SECURITY_SCAN_NOTES.md.
|
|
716
|
-
const { runSkillCli } = require('./skill-runner');
|
|
717
|
-
|
|
718
|
-
// Assign to (rather than replace) module.exports so that modules which have
|
|
719
|
-
// already captured a reference during circular require — notably
|
|
720
|
-
// ./skill-registry — observe the final API once this module finishes loading.
|
|
721
|
-
Object.assign(module.exports, {
|
|
722
|
-
INSTALL_MANIFEST_FILE,
|
|
723
|
-
INTEGRITY_FILE,
|
|
724
|
-
SKILL_CONTRACT_FILE,
|
|
725
|
-
applySkillInstall,
|
|
726
|
-
buildAdapterTools,
|
|
727
|
-
cleanupStaging,
|
|
728
|
-
discoverLocalSkills,
|
|
729
|
-
discoverSkillsFromSources,
|
|
730
|
-
fetchSkillsRegistry,
|
|
731
|
-
getLegacyOpenClawSkillState,
|
|
732
|
-
getOpenClawConfigPath,
|
|
733
|
-
getSkillsState,
|
|
734
|
-
installSkillFromRegistry,
|
|
735
|
-
isSkillEnabled,
|
|
736
|
-
listSkillDirectories,
|
|
737
|
-
loadSkillContract,
|
|
738
|
-
normalizeSkillMetadata,
|
|
739
|
-
planSkillInstall,
|
|
740
|
-
readSkillById,
|
|
741
|
-
readSkillByIdFromSources,
|
|
742
|
-
readSkillIntegrity,
|
|
743
|
-
registerOpenClawTools,
|
|
744
|
-
resolveSkillSources,
|
|
745
|
-
resolveSkillsDir,
|
|
746
|
-
resolveOpenClawPluginEntry,
|
|
747
|
-
runSkillCli,
|
|
748
|
-
skillToolActionName,
|
|
749
|
-
toolNameToActionSegment,
|
|
750
|
-
verifySkillIntegrity,
|
|
751
|
-
writeIntegrityManifest,
|
|
752
|
-
});
|
|
753
|
-
|
|
754
|
-
// After our own exports are populated, pull in the registry factory. This must
|
|
755
|
-
// happen last so skill-registry.js sees a fully-populated skills API.
|
|
756
|
-
const skillRegistry = require('./skill-registry');
|
|
757
|
-
module.exports.createSkillRegistry = skillRegistry.createSkillRegistry;
|
|
758
|
-
module.exports.purgeRequireCacheFor = skillRegistry.purgeRequireCacheFor;
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { extractZipBuffer } = require('./zip-extract');
|
|
8
|
+
const { ensureDir, readJson, safeStat } = require('./fs-io');
|
|
9
|
+
const { getOpenClawConfigPath } = require('./openclaw-paths');
|
|
10
|
+
const safeNpm = require('./safe-npm');
|
|
11
|
+
|
|
12
|
+
const SKILL_CONTRACT_FILE = 'skill.contract.js';
|
|
13
|
+
const INTEGRITY_FILE = '.integrity.json';
|
|
14
|
+
const INSTALL_MANIFEST_FILE = 'skills-install.json';
|
|
15
|
+
|
|
16
|
+
function toolNameToActionSegment(name) {
|
|
17
|
+
return String(name || '')
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
20
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
21
|
+
.replace(/^-+|-+$/g, '')
|
|
22
|
+
.toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function skillToolActionName(skillId, toolName) {
|
|
26
|
+
const action = toolNameToActionSegment(toolName);
|
|
27
|
+
return `skill/${skillId}/${action || 'run'}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadSkillContract(skillDir) {
|
|
31
|
+
const contractPath = path.resolve(skillDir, SKILL_CONTRACT_FILE);
|
|
32
|
+
if (!fs.existsSync(contractPath)) return null;
|
|
33
|
+
delete require.cache[require.resolve(contractPath)];
|
|
34
|
+
return require(contractPath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasSkillContract(skillDir) {
|
|
38
|
+
return fs.existsSync(path.join(skillDir, SKILL_CONTRACT_FILE));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 列出某个目录下被视为「候选 skill 目录」的直接子项绝对路径。
|
|
43
|
+
*
|
|
44
|
+
* 与裸 readdirSync 相比多做两件事:
|
|
45
|
+
* 1. 把 symlink-to-directory 也视为目录(Dirent.isDirectory() 对 symlink 为 false)。
|
|
46
|
+
* 2. 对于不存在 / 不可读的目录,返回空数组而不是抛错。
|
|
47
|
+
*
|
|
48
|
+
* 只扫 1 层,不递归。
|
|
49
|
+
*/
|
|
50
|
+
function listSkillDirectories(dir) {
|
|
51
|
+
if (!dir || !fs.existsSync(dir)) return [];
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
55
|
+
} catch (_) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const results = [];
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const full = path.join(dir, entry.name);
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
results.push(full);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (entry.isSymbolicLink()) {
|
|
66
|
+
const stat = safeStat(full);
|
|
67
|
+
if (stat && stat.isDirectory()) results.push(full);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return results;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveSkillsDir(paths, config = {}) {
|
|
74
|
+
if (config.skillsDir) {
|
|
75
|
+
return path.resolve(config.skillsDir);
|
|
76
|
+
}
|
|
77
|
+
if (paths && paths.skillsDir) {
|
|
78
|
+
return paths.skillsDir;
|
|
79
|
+
}
|
|
80
|
+
if (paths && paths.baseDir) {
|
|
81
|
+
return path.join(paths.baseDir, 'skills');
|
|
82
|
+
}
|
|
83
|
+
return path.resolve('skills');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 归一化多源配置:primary + extras。
|
|
88
|
+
*
|
|
89
|
+
* 入参:
|
|
90
|
+
* - { primary, extras }:primary 是单个目录字符串;extras 是字符串数组或 undefined。
|
|
91
|
+
* - 或 { paths, config }:会自动走 resolveSkillsDir 推导 primary,并读 config.extraSkillDirs。
|
|
92
|
+
*
|
|
93
|
+
* 出参:
|
|
94
|
+
* {
|
|
95
|
+
* primary: '/abs/primary',
|
|
96
|
+
* extras: [{ path: '/abs/x', kind: 'skill' | 'dir' }, ...],
|
|
97
|
+
* invalid: [{ path, reason }] // 不存在或读不到的 extra 条目
|
|
98
|
+
* }
|
|
99
|
+
*
|
|
100
|
+
* kind 判定:自身含 skill.contract.js => 'skill';否则当作父目录 'dir'。
|
|
101
|
+
* 对重复路径做去重(primary 自己也不会出现在 extras 里)。
|
|
102
|
+
*/
|
|
103
|
+
function resolveSkillSources(input = {}) {
|
|
104
|
+
let primary = input.primary;
|
|
105
|
+
let extrasInput = input.extras;
|
|
106
|
+
|
|
107
|
+
if (!primary) {
|
|
108
|
+
primary = resolveSkillsDir(input.paths, input.config || {});
|
|
109
|
+
}
|
|
110
|
+
primary = primary ? path.resolve(primary) : '';
|
|
111
|
+
|
|
112
|
+
if (!extrasInput && input.config && Array.isArray(input.config.extraSkillDirs)) {
|
|
113
|
+
extrasInput = input.config.extraSkillDirs;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const rawExtras = Array.isArray(extrasInput) ? extrasInput : [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
if (primary) seen.add(primary);
|
|
119
|
+
|
|
120
|
+
const extras = [];
|
|
121
|
+
const invalid = [];
|
|
122
|
+
for (const raw of rawExtras) {
|
|
123
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
124
|
+
invalid.push({ path: String(raw), reason: 'invalid-type' });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const abs = path.resolve(raw);
|
|
128
|
+
if (seen.has(abs)) continue;
|
|
129
|
+
seen.add(abs);
|
|
130
|
+
|
|
131
|
+
if (!fs.existsSync(abs)) {
|
|
132
|
+
invalid.push({ path: abs, reason: 'not-found' });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const stat = safeStat(abs);
|
|
136
|
+
if (!stat || !stat.isDirectory()) {
|
|
137
|
+
invalid.push({ path: abs, reason: 'not-a-directory' });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const kind = hasSkillContract(abs) ? 'skill' : 'dir';
|
|
141
|
+
extras.push({ path: abs, kind });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { primary, extras, invalid };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function normalizeSkillMetadata(skillDir) {
|
|
148
|
+
const contract = loadSkillContract(skillDir);
|
|
149
|
+
const pkg = readJson(path.join(skillDir, 'package.json')) || {};
|
|
150
|
+
const cli = contract && contract.cli ? contract.cli : {};
|
|
151
|
+
const openclaw = contract && contract.openclaw ? contract.openclaw : {};
|
|
152
|
+
const tools = Array.isArray(openclaw.tools)
|
|
153
|
+
? openclaw.tools.map((tool) => tool.name)
|
|
154
|
+
: [];
|
|
155
|
+
const commands = Array.isArray(cli.commands)
|
|
156
|
+
? cli.commands.map((command) => command.name)
|
|
157
|
+
: [];
|
|
158
|
+
|
|
159
|
+
const id = contract?.id || pkg.name || path.basename(skillDir);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
id,
|
|
163
|
+
name: contract?.name || pkg.name || path.basename(skillDir),
|
|
164
|
+
version: contract?.version || pkg.version || '1.0.0',
|
|
165
|
+
description: contract?.description || pkg.description || '',
|
|
166
|
+
skillDir,
|
|
167
|
+
cliEntry: cli.entry ? path.resolve(skillDir, cli.entry) : path.join(skillDir, 'index.js'),
|
|
168
|
+
commands,
|
|
169
|
+
tools,
|
|
170
|
+
actions: tools.map((tool) => skillToolActionName(id, tool)),
|
|
171
|
+
runtime: contract?.runtime || {},
|
|
172
|
+
contract,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function discoverLocalSkills(skillsDir) {
|
|
177
|
+
return listSkillDirectories(skillsDir)
|
|
178
|
+
.filter((skillDir) => hasSkillContract(skillDir))
|
|
179
|
+
.map((skillDir) => normalizeSkillMetadata(skillDir))
|
|
180
|
+
.filter((skill) => skill && skill.id);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readSkillById(skillsDir, skillId) {
|
|
184
|
+
const skillDir = path.join(skillsDir, skillId);
|
|
185
|
+
if (!fs.existsSync(skillDir) || !hasSkillContract(skillDir)) return null;
|
|
186
|
+
return normalizeSkillMetadata(skillDir);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 按 sources 发现所有可用 skill,返回带 source / sourcePath 注解的统一列表。
|
|
191
|
+
*
|
|
192
|
+
* sources 由 resolveSkillSources() 生成;也接受简化形态 { primary, extras: [string] }
|
|
193
|
+
* (会内部再跑一遍 resolveSkillSources 归一化)。
|
|
194
|
+
*
|
|
195
|
+
* 冲突策略:同 id 多源命中时 primary 优先;后续 extras 里的同 id 被跳过。
|
|
196
|
+
* 每次跳过会回调 onConflict({ id, winner, loser }),调用方自行决定是否打日志。
|
|
197
|
+
*
|
|
198
|
+
* 返回值:
|
|
199
|
+
* {
|
|
200
|
+
* skills: [{ ...normalizeSkillMetadata, source: 'primary'|'extra', sourcePath }],
|
|
201
|
+
* conflicts: [{ id, winner: { source, path }, loser: { source, path } }],
|
|
202
|
+
* invalid: sources.invalid // 透传,便于 CLI 展示
|
|
203
|
+
* }
|
|
204
|
+
*/
|
|
205
|
+
function discoverSkillsFromSources(sources, options = {}) {
|
|
206
|
+
const normalized = sources && Array.isArray(sources.extras) && sources.extras.every((e) => e && typeof e === 'object')
|
|
207
|
+
? sources
|
|
208
|
+
: resolveSkillSources(sources || {});
|
|
209
|
+
|
|
210
|
+
const { primary, extras, invalid = [] } = normalized;
|
|
211
|
+
const { onConflict } = options;
|
|
212
|
+
|
|
213
|
+
const byId = new Map();
|
|
214
|
+
const conflicts = [];
|
|
215
|
+
|
|
216
|
+
const register = (skill, source, sourcePath) => {
|
|
217
|
+
if (!skill || !skill.id) return;
|
|
218
|
+
if (byId.has(skill.id)) {
|
|
219
|
+
const existing = byId.get(skill.id);
|
|
220
|
+
const conflict = {
|
|
221
|
+
id: skill.id,
|
|
222
|
+
winner: { source: existing.source, path: existing.sourcePath },
|
|
223
|
+
loser: { source, path: sourcePath },
|
|
224
|
+
};
|
|
225
|
+
conflicts.push(conflict);
|
|
226
|
+
if (typeof onConflict === 'function') {
|
|
227
|
+
try { onConflict(conflict); } catch (_) { /* noop */ }
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
byId.set(skill.id, { ...skill, source, sourcePath });
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
if (primary) {
|
|
235
|
+
for (const skill of discoverLocalSkills(primary)) {
|
|
236
|
+
register(skill, 'primary', primary);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
for (const extra of extras) {
|
|
241
|
+
if (extra.kind === 'skill') {
|
|
242
|
+
if (!hasSkillContract(extra.path)) continue;
|
|
243
|
+
const meta = normalizeSkillMetadata(extra.path);
|
|
244
|
+
if (meta && meta.id) register(meta, 'extra', extra.path);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
for (const skill of discoverLocalSkills(extra.path)) {
|
|
248
|
+
register(skill, 'extra', extra.path);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
skills: Array.from(byId.values()),
|
|
254
|
+
conflicts,
|
|
255
|
+
invalid,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 在 primary + extras 的联合范围内按 id 找 skill。
|
|
261
|
+
* primary 优先;extras 按传入顺序其次。命中返回带 source / sourcePath 的对象,未命中返回 null。
|
|
262
|
+
*/
|
|
263
|
+
function readSkillByIdFromSources(input = {}) {
|
|
264
|
+
const { id } = input;
|
|
265
|
+
if (!id) return null;
|
|
266
|
+
const { primary, extras } = Array.isArray(input.extras) && input.extras.every((e) => e && typeof e === 'object')
|
|
267
|
+
? input
|
|
268
|
+
: resolveSkillSources(input);
|
|
269
|
+
|
|
270
|
+
if (primary) {
|
|
271
|
+
const hit = readSkillById(primary, id);
|
|
272
|
+
if (hit) return { ...hit, source: 'primary', sourcePath: primary };
|
|
273
|
+
}
|
|
274
|
+
for (const extra of extras) {
|
|
275
|
+
if (extra.kind === 'skill') {
|
|
276
|
+
if (!hasSkillContract(extra.path)) continue;
|
|
277
|
+
const meta = normalizeSkillMetadata(extra.path);
|
|
278
|
+
if (meta && meta.id === id) return { ...meta, source: 'extra', sourcePath: extra.path };
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const hit = readSkillById(extra.path, id);
|
|
282
|
+
if (hit) return { ...hit, source: 'extra', sourcePath: extra.path };
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Registry network I/O lives in registry-client.js so `fetch(…)` is not
|
|
288
|
+
// co-located with `fs.readFileSync(…)` / `fs.createReadStream(…)` in this
|
|
289
|
+
// module. Re-exported below for backwards compatibility. See
|
|
290
|
+
// SECURITY_SCAN_NOTES.md.
|
|
291
|
+
const { fetchSkillsRegistry, downloadBuffer } = require('./registry-client');
|
|
292
|
+
|
|
293
|
+
function resolveOpenClawPluginEntry(definition) {
|
|
294
|
+
try {
|
|
295
|
+
const sdk = require('openclaw/plugin-sdk/plugin-entry');
|
|
296
|
+
if (typeof sdk.definePluginEntry === 'function') {
|
|
297
|
+
return sdk.definePluginEntry(definition);
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
// Fallback for local development without the OpenClaw SDK package installed.
|
|
301
|
+
}
|
|
302
|
+
return definition.register;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function getSkillsState(config = {}) {
|
|
306
|
+
const state = config && typeof config === 'object' ? config.skillsEnabled : null;
|
|
307
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
|
308
|
+
return {};
|
|
309
|
+
}
|
|
310
|
+
return state;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function getLegacyOpenClawSkillState(options = {}) {
|
|
314
|
+
const {
|
|
315
|
+
openclawConfigPath = getOpenClawConfigPath(options),
|
|
316
|
+
skillIds = null,
|
|
317
|
+
} = options;
|
|
318
|
+
|
|
319
|
+
if (!fs.existsSync(openclawConfigPath)) {
|
|
320
|
+
return {};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let config = null;
|
|
324
|
+
try {
|
|
325
|
+
config = JSON.parse(fs.readFileSync(openclawConfigPath, 'utf8'));
|
|
326
|
+
} catch {
|
|
327
|
+
return {};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const entries = config?.plugins?.entries;
|
|
331
|
+
if (!entries || typeof entries !== 'object') {
|
|
332
|
+
return {};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const allowedSkillIds = Array.isArray(skillIds) && skillIds.length > 0
|
|
336
|
+
? new Set(skillIds)
|
|
337
|
+
: null;
|
|
338
|
+
const state = {};
|
|
339
|
+
|
|
340
|
+
for (const [skillId, entry] of Object.entries(entries)) {
|
|
341
|
+
if (skillId === 'js-eyes') {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (allowedSkillIds && !allowedSkillIds.has(skillId)) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (!entry || typeof entry !== 'object' || entry.enabled === undefined) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
state[skillId] = entry.enabled !== false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return state;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function isSkillEnabled(config = {}, skillId, legacyState = {}) {
|
|
357
|
+
const state = getSkillsState(config);
|
|
358
|
+
if (Object.prototype.hasOwnProperty.call(state, skillId)) {
|
|
359
|
+
return state[skillId] !== false;
|
|
360
|
+
}
|
|
361
|
+
if (legacyState && Object.prototype.hasOwnProperty.call(legacyState, skillId)) {
|
|
362
|
+
return legacyState[skillId] !== false;
|
|
363
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 从 adapter.tools 构建 OpenClaw 工具定义列表(不执行 api.registerTool)。
|
|
369
|
+
*
|
|
370
|
+
* 用于 SkillRegistry 等需要"先收集再统一注册"的场景;registerOpenClawTools
|
|
371
|
+
* 的注册循环基于此函数的输出。
|
|
372
|
+
*
|
|
373
|
+
* 返回:
|
|
374
|
+
* {
|
|
375
|
+
* toolDefs: [{ definition, optional, toolName }], // 通过过滤的工具
|
|
376
|
+
* summary: { registered: [], skipped: [{ name, reason }], failed: [] },
|
|
377
|
+
* }
|
|
378
|
+
*
|
|
379
|
+
* 注意:本函数不会写入 registeredNames;由调用方在成功注册后维护。
|
|
380
|
+
*/
|
|
381
|
+
function buildAdapterTools(adapter, options = {}) {
|
|
382
|
+
const logger = options.logger || console;
|
|
383
|
+
const sourceName = options.sourceName || adapter?.id || 'js-eyes-skill';
|
|
384
|
+
const declaredTools = Array.isArray(options.declaredTools) && options.declaredTools.length > 0
|
|
385
|
+
? new Set(options.declaredTools)
|
|
386
|
+
: null;
|
|
387
|
+
const registeredNames = options.registeredNames || null;
|
|
388
|
+
const wrapTool = typeof options.wrapTool === 'function' ? options.wrapTool : null;
|
|
389
|
+
|
|
390
|
+
const toolDefs = [];
|
|
391
|
+
const summary = {
|
|
392
|
+
registered: [],
|
|
393
|
+
skipped: [],
|
|
394
|
+
failed: [],
|
|
395
|
+
};
|
|
396
|
+
// Track names we've already emitted in this batch so intra-batch duplicates
|
|
397
|
+
// are rejected even when registeredNames is not mutated here.
|
|
398
|
+
const localSeen = new Set();
|
|
399
|
+
|
|
400
|
+
for (const tool of (adapter && adapter.tools) || []) {
|
|
401
|
+
if (!tool || !tool.name) {
|
|
402
|
+
summary.skipped.push({ name: '(anonymous)', reason: 'missing-name' });
|
|
403
|
+
logger.warn(`[js-eyes] Skipping tool with missing name from ${sourceName}`);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (declaredTools && !declaredTools.has(tool.name)) {
|
|
407
|
+
summary.skipped.push({ name: tool.name, reason: 'undeclared' });
|
|
408
|
+
logger.warn(`[js-eyes] Skipping undeclared tool "${tool.name}" from ${sourceName}`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if ((registeredNames && registeredNames.has(tool.name)) || localSeen.has(tool.name)) {
|
|
412
|
+
summary.skipped.push({ name: tool.name, reason: 'duplicate-name' });
|
|
413
|
+
logger.warn(`[js-eyes] Skipping duplicate tool "${tool.name}" from ${sourceName}`);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
localSeen.add(tool.name);
|
|
417
|
+
|
|
418
|
+
const definition = {
|
|
419
|
+
name: tool.name,
|
|
420
|
+
label: tool.label,
|
|
421
|
+
description: tool.description,
|
|
422
|
+
parameters: tool.parameters,
|
|
423
|
+
execute: tool.execute,
|
|
424
|
+
};
|
|
425
|
+
const wrapped = wrapTool ? wrapTool(definition, { source: sourceName }) : definition;
|
|
426
|
+
|
|
427
|
+
toolDefs.push({
|
|
428
|
+
toolName: tool.name,
|
|
429
|
+
definition: wrapped,
|
|
430
|
+
optional: Boolean(tool.optional),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return { toolDefs, summary };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function registerOpenClawTools(api, adapter, options = {}) {
|
|
438
|
+
const logger = options.logger || api.logger || console;
|
|
439
|
+
const registeredNames = options.registeredNames || null;
|
|
440
|
+
const sourceName = options.sourceName || adapter?.id || 'js-eyes-skill';
|
|
441
|
+
|
|
442
|
+
const { toolDefs, summary } = buildAdapterTools(adapter, {
|
|
443
|
+
logger,
|
|
444
|
+
sourceName,
|
|
445
|
+
declaredTools: options.declaredTools,
|
|
446
|
+
registeredNames,
|
|
447
|
+
wrapTool: options.wrapTool,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
for (const entry of toolDefs) {
|
|
451
|
+
try {
|
|
452
|
+
api.registerTool(entry.definition, entry.optional ? { optional: true } : undefined);
|
|
453
|
+
if (registeredNames) {
|
|
454
|
+
registeredNames.add(entry.toolName);
|
|
455
|
+
}
|
|
456
|
+
summary.registered.push(entry.toolName);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
summary.failed.push({ name: entry.toolName, reason: error.message });
|
|
459
|
+
logger.warn(`[js-eyes] Failed to register tool "${entry.toolName}" from ${sourceName}: ${error.message}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return summary;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function sha256(buffer) {
|
|
467
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function isMainRefUrl(url) {
|
|
471
|
+
if (typeof url !== 'string') return false;
|
|
472
|
+
return /\/(refs\/heads\/)?main(?=[/?])/.test(url);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const detectPackageManager = safeNpm.detectPackageManager;
|
|
476
|
+
|
|
477
|
+
function installSkillDependencies(targetDir, options = {}) {
|
|
478
|
+
return safeNpm.installSkillDependencies(targetDir, options);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function listFilesRecursive(dir) {
|
|
482
|
+
const out = [];
|
|
483
|
+
function walk(current) {
|
|
484
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
485
|
+
for (const entry of entries) {
|
|
486
|
+
const full = path.join(current, entry.name);
|
|
487
|
+
const rel = path.relative(dir, full);
|
|
488
|
+
if (rel === INTEGRITY_FILE || rel === INSTALL_MANIFEST_FILE) continue;
|
|
489
|
+
if (rel.split(path.sep)[0] === 'node_modules') continue;
|
|
490
|
+
if (entry.isDirectory()) {
|
|
491
|
+
walk(full);
|
|
492
|
+
} else if (entry.isFile()) {
|
|
493
|
+
out.push(rel.split(path.sep).join('/'));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
walk(dir);
|
|
498
|
+
return out.sort();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function writeIntegrityManifest(targetDir, payload = {}) {
|
|
502
|
+
const files = {};
|
|
503
|
+
for (const rel of listFilesRecursive(targetDir)) {
|
|
504
|
+
const full = path.join(targetDir, rel);
|
|
505
|
+
files[rel] = sha256(fs.readFileSync(full));
|
|
506
|
+
}
|
|
507
|
+
const manifest = {
|
|
508
|
+
version: 1,
|
|
509
|
+
createdAt: new Date().toISOString(),
|
|
510
|
+
skillId: payload.skillId || null,
|
|
511
|
+
sourceUrl: payload.sourceUrl || null,
|
|
512
|
+
bundleSha256: payload.bundleSha256 || null,
|
|
513
|
+
declaredTools: payload.declaredTools || [],
|
|
514
|
+
files,
|
|
515
|
+
};
|
|
516
|
+
const filePath = path.join(targetDir, INTEGRITY_FILE);
|
|
517
|
+
fs.writeFileSync(filePath, JSON.stringify(manifest, null, 2) + '\n');
|
|
518
|
+
try { fs.chmodSync(filePath, 0o600); } catch {}
|
|
519
|
+
return manifest;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function readSkillIntegrity(skillDir) {
|
|
523
|
+
const filePath = path.join(skillDir, INTEGRITY_FILE);
|
|
524
|
+
if (!fs.existsSync(filePath)) return null;
|
|
525
|
+
try {
|
|
526
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
527
|
+
} catch {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function verifySkillIntegrity(skillDir) {
|
|
533
|
+
const manifest = readSkillIntegrity(skillDir);
|
|
534
|
+
if (!manifest || !manifest.files) {
|
|
535
|
+
return { hasIntegrity: false, ok: false, mismatches: [], missing: [], extra: [], checked: 0 };
|
|
536
|
+
}
|
|
537
|
+
const expected = manifest.files;
|
|
538
|
+
const expectedKeys = Object.keys(expected);
|
|
539
|
+
const present = new Set(listFilesRecursive(skillDir));
|
|
540
|
+
|
|
541
|
+
const mismatches = [];
|
|
542
|
+
const missing = [];
|
|
543
|
+
|
|
544
|
+
for (const rel of expectedKeys) {
|
|
545
|
+
const full = path.join(skillDir, rel);
|
|
546
|
+
if (!fs.existsSync(full)) {
|
|
547
|
+
missing.push(rel);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
const actual = sha256(fs.readFileSync(full));
|
|
551
|
+
if (actual !== expected[rel]) {
|
|
552
|
+
mismatches.push(rel);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const extra = [...present].filter((rel) => !Object.prototype.hasOwnProperty.call(expected, rel));
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
hasIntegrity: true,
|
|
560
|
+
ok: mismatches.length === 0 && missing.length === 0,
|
|
561
|
+
mismatches,
|
|
562
|
+
missing,
|
|
563
|
+
extra,
|
|
564
|
+
checked: expectedKeys.length,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function planSkillInstall(options) {
|
|
569
|
+
const {
|
|
570
|
+
skillId,
|
|
571
|
+
registryUrl,
|
|
572
|
+
skillsDir,
|
|
573
|
+
stagingDir = path.join(os.tmpdir(), `js-eyes-skill-staging-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`),
|
|
574
|
+
force = false,
|
|
575
|
+
logger = console,
|
|
576
|
+
} = options;
|
|
577
|
+
|
|
578
|
+
ensureDir(skillsDir);
|
|
579
|
+
const registry = await fetchSkillsRegistry(registryUrl);
|
|
580
|
+
const skill = registry.skills?.find((entry) => entry.id === skillId);
|
|
581
|
+
if (!skill) {
|
|
582
|
+
const ids = (registry.skills || []).map((entry) => entry.id).join(', ');
|
|
583
|
+
throw new Error(`技能 "${skillId}" 未在注册表中找到。可用技能: ${ids || '无'}`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const targetDir = path.join(skillsDir, skillId);
|
|
587
|
+
if (fs.existsSync(targetDir) && !force) {
|
|
588
|
+
throw new Error(`技能 "${skillId}" 已安装在 ${targetDir}(使用 force=true 覆盖)`);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (!skill.sha256 || typeof skill.sha256 !== 'string') {
|
|
592
|
+
throw new Error(`技能 "${skillId}" 注册表条目缺少 sha256 校验和,拒绝下载`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const candidateUrls = [skill.downloadUrl];
|
|
596
|
+
if (skill.downloadUrlFallback) {
|
|
597
|
+
if (isMainRefUrl(skill.downloadUrlFallback)) {
|
|
598
|
+
logger?.warn?.(`[js-eyes] Ignoring @main fallback URL for ${skillId} (use a tagged URL)`);
|
|
599
|
+
} else {
|
|
600
|
+
candidateUrls.push(skill.downloadUrlFallback);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const { buffer, url } = await downloadBuffer(candidateUrls.filter(Boolean), logger);
|
|
605
|
+
const digest = sha256(buffer);
|
|
606
|
+
if (digest !== skill.sha256.toLowerCase()) {
|
|
607
|
+
throw new Error(`技能 "${skillId}" 哈希不符: expected ${skill.sha256}, got ${digest}`);
|
|
608
|
+
}
|
|
609
|
+
if (typeof skill.size === 'number' && buffer.length !== skill.size) {
|
|
610
|
+
throw new Error(`技能 "${skillId}" 大小不符: expected ${skill.size}, got ${buffer.length}`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
ensureDir(stagingDir);
|
|
614
|
+
if (fs.existsSync(stagingDir) && fs.readdirSync(stagingDir).length > 0) {
|
|
615
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
616
|
+
ensureDir(stagingDir);
|
|
617
|
+
}
|
|
618
|
+
extractZipBuffer(buffer, stagingDir);
|
|
619
|
+
|
|
620
|
+
const stagedFiles = listFilesRecursive(stagingDir);
|
|
621
|
+
const declaredTools = Array.isArray(skill.tools) ? skill.tools.slice() : [];
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
plan: {
|
|
625
|
+
skillId,
|
|
626
|
+
registryUrl,
|
|
627
|
+
sourceUrl: url,
|
|
628
|
+
bundleSha256: digest,
|
|
629
|
+
bundleSize: buffer.length,
|
|
630
|
+
targetDir,
|
|
631
|
+
stagingDir,
|
|
632
|
+
declaredTools,
|
|
633
|
+
stagedFiles,
|
|
634
|
+
registryEntry: skill,
|
|
635
|
+
hasLockfile: fs.existsSync(path.join(stagingDir, 'package-lock.json')),
|
|
636
|
+
hasPackageJson: fs.existsSync(path.join(stagingDir, 'package.json')),
|
|
637
|
+
},
|
|
638
|
+
skill,
|
|
639
|
+
registry,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function cleanupStaging(stagingDir) {
|
|
644
|
+
if (stagingDir && fs.existsSync(stagingDir)) {
|
|
645
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function applySkillInstall(plan, options = {}) {
|
|
650
|
+
if (!plan || !plan.stagingDir || !plan.targetDir) {
|
|
651
|
+
throw new Error('applySkillInstall: invalid plan');
|
|
652
|
+
}
|
|
653
|
+
const requireLockfile = options.requireLockfile !== false;
|
|
654
|
+
if (plan.hasPackageJson && requireLockfile && !plan.hasLockfile) {
|
|
655
|
+
cleanupStaging(plan.stagingDir);
|
|
656
|
+
throw new Error('技能包缺少 package-lock.json,拒绝安装(设置 security.requireLockfile=false 可放宽)');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
installSkillDependencies(plan.stagingDir, {
|
|
660
|
+
requireLockfile,
|
|
661
|
+
allowPostinstall: Boolean(options.allowPostinstall),
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
if (fs.existsSync(plan.targetDir)) {
|
|
665
|
+
fs.rmSync(plan.targetDir, { recursive: true, force: true });
|
|
666
|
+
}
|
|
667
|
+
ensureDir(path.dirname(plan.targetDir));
|
|
668
|
+
fs.renameSync(plan.stagingDir, plan.targetDir);
|
|
669
|
+
|
|
670
|
+
const integrity = writeIntegrityManifest(plan.targetDir, {
|
|
671
|
+
skillId: plan.skillId,
|
|
672
|
+
sourceUrl: plan.sourceUrl,
|
|
673
|
+
bundleSha256: plan.bundleSha256,
|
|
674
|
+
declaredTools: plan.declaredTools,
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
const installManifest = {
|
|
678
|
+
skillId: plan.skillId,
|
|
679
|
+
sourceUrl: plan.sourceUrl,
|
|
680
|
+
bundleSha256: plan.bundleSha256,
|
|
681
|
+
bundleSize: plan.bundleSize,
|
|
682
|
+
installedAt: integrity.createdAt,
|
|
683
|
+
declaredTools: plan.declaredTools,
|
|
684
|
+
};
|
|
685
|
+
const installManifestPath = path.join(plan.targetDir, INSTALL_MANIFEST_FILE);
|
|
686
|
+
fs.writeFileSync(installManifestPath, JSON.stringify(installManifest, null, 2) + '\n');
|
|
687
|
+
try { fs.chmodSync(installManifestPath, 0o600); } catch {}
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
targetDir: plan.targetDir,
|
|
691
|
+
integrity,
|
|
692
|
+
installManifest,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function installSkillFromRegistry(options) {
|
|
697
|
+
const { plan, skill, registry } = await planSkillInstall(options);
|
|
698
|
+
const apply = applySkillInstall(plan, {
|
|
699
|
+
requireLockfile: options.requireLockfile,
|
|
700
|
+
allowPostinstall: options.allowPostinstall,
|
|
701
|
+
});
|
|
702
|
+
return {
|
|
703
|
+
registry,
|
|
704
|
+
skill,
|
|
705
|
+
targetDir: apply.targetDir,
|
|
706
|
+
integrity: apply.integrity,
|
|
707
|
+
installManifest: apply.installManifest,
|
|
708
|
+
plan,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// `runSkillCli` is the only remaining child_process caller in @js-eyes/protocol
|
|
713
|
+
// outside the hardened `safe-npm.js` module. It lives in its own file so the
|
|
714
|
+
// child_process import is not co-located with `fetch(registryUrl)` / other
|
|
715
|
+
// network code in this module. See SECURITY_SCAN_NOTES.md.
|
|
716
|
+
const { runSkillCli } = require('./skill-runner');
|
|
717
|
+
|
|
718
|
+
// Assign to (rather than replace) module.exports so that modules which have
|
|
719
|
+
// already captured a reference during circular require — notably
|
|
720
|
+
// ./skill-registry — observe the final API once this module finishes loading.
|
|
721
|
+
Object.assign(module.exports, {
|
|
722
|
+
INSTALL_MANIFEST_FILE,
|
|
723
|
+
INTEGRITY_FILE,
|
|
724
|
+
SKILL_CONTRACT_FILE,
|
|
725
|
+
applySkillInstall,
|
|
726
|
+
buildAdapterTools,
|
|
727
|
+
cleanupStaging,
|
|
728
|
+
discoverLocalSkills,
|
|
729
|
+
discoverSkillsFromSources,
|
|
730
|
+
fetchSkillsRegistry,
|
|
731
|
+
getLegacyOpenClawSkillState,
|
|
732
|
+
getOpenClawConfigPath,
|
|
733
|
+
getSkillsState,
|
|
734
|
+
installSkillFromRegistry,
|
|
735
|
+
isSkillEnabled,
|
|
736
|
+
listSkillDirectories,
|
|
737
|
+
loadSkillContract,
|
|
738
|
+
normalizeSkillMetadata,
|
|
739
|
+
planSkillInstall,
|
|
740
|
+
readSkillById,
|
|
741
|
+
readSkillByIdFromSources,
|
|
742
|
+
readSkillIntegrity,
|
|
743
|
+
registerOpenClawTools,
|
|
744
|
+
resolveSkillSources,
|
|
745
|
+
resolveSkillsDir,
|
|
746
|
+
resolveOpenClawPluginEntry,
|
|
747
|
+
runSkillCli,
|
|
748
|
+
skillToolActionName,
|
|
749
|
+
toolNameToActionSegment,
|
|
750
|
+
verifySkillIntegrity,
|
|
751
|
+
writeIntegrityManifest,
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// After our own exports are populated, pull in the registry factory. This must
|
|
755
|
+
// happen last so skill-registry.js sees a fully-populated skills API.
|
|
756
|
+
const skillRegistry = require('./skill-registry');
|
|
757
|
+
module.exports.createSkillRegistry = skillRegistry.createSkillRegistry;
|
|
758
|
+
module.exports.purgeRequireCacheFor = skillRegistry.purgeRequireCacheFor;
|