openworkgraph 0.2.12 → 0.3.0
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/dist/api.d.ts +1 -0
- package/dist/api.js +6 -0
- package/dist/backend/adapter.d.ts +1 -0
- package/dist/backend/adapter.js +161 -8
- package/dist/backend/stdio.d.ts +1 -1
- package/dist/backend/stdio.js +2 -2
- package/dist/backend/types.d.ts +12 -0
- package/dist/builtin-skills.d.ts +56 -0
- package/dist/builtin-skills.js +514 -0
- package/dist/cli.js +0 -0
- package/dist/graph-transfer.js +7 -2
- package/dist/inputs.d.ts +1 -0
- package/dist/inputs.js +1 -1
- package/dist/plugins.d.ts +4 -0
- package/dist/plugins.js +33 -1
- package/dist/runtime-backend.d.ts +3 -1
- package/dist/runtime-backend.js +11 -2
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.js +17 -2
- package/dist/service.js +7 -5
- package/dist/skill-configs.d.ts +43 -0
- package/dist/skill-configs.js +554 -0
- package/dist/skill-schema.d.ts +10 -0
- package/dist/skill-schema.js +144 -0
- package/dist/skill-sessions.d.ts +24 -0
- package/dist/skill-sessions.js +165 -0
- package/dist/skill-source.d.ts +37 -0
- package/dist/skill-source.js +104 -0
- package/dist/skills-api.d.ts +8 -4
- package/dist/skills-api.js +48 -27
- package/dist/skills-management-api.d.ts +12 -0
- package/dist/skills-management-api.js +139 -0
- package/dist/vendor/protocol/index.d.ts +3 -1
- package/dist/vendor/protocol/index.js +2 -1
- package/dist/vendor/protocol/skills.d.ts +107 -0
- package/dist/vendor/protocol/skills.js +33 -0
- package/package.json +2 -2
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { isSkillName } from "./vendor/protocol/index.js";
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { homedir, hostname } from 'node:os';
|
|
5
|
+
import { join, resolve, dirname } from 'node:path';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { parseDocument } from 'yaml';
|
|
8
|
+
import { ServiceError } from './errors.js';
|
|
9
|
+
import { parseSkillConfig } from './skill-schema.js';
|
|
10
|
+
import { DEFAULT_SKILL_SOURCE, GitHubSkillSource, SKILL_LIMITS, gitBlobSha, packageDigest, validatePackageFiles, safeSkillPath } from './skill-source.js';
|
|
11
|
+
const key = (id) => createHash('sha256').update(id).digest('hex');
|
|
12
|
+
const isMissing = (error) => error.code === 'ENOENT';
|
|
13
|
+
class SkillDeadline {
|
|
14
|
+
expires;
|
|
15
|
+
constructor(duration) { this.expires = Date.now() + duration; }
|
|
16
|
+
check() { if (Date.now() >= this.expires)
|
|
17
|
+
throw new ServiceError('CONFLICT', '技能操作超过总时限,请稍后重试。', { retryable: true }); }
|
|
18
|
+
async wait(work) {
|
|
19
|
+
this.check();
|
|
20
|
+
let timer;
|
|
21
|
+
try {
|
|
22
|
+
const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new ServiceError('CONFLICT', '技能操作超过总时限,请稍后重试。', { retryable: true })), Math.max(1, this.expires - Date.now())); });
|
|
23
|
+
const result = await Promise.race([work(), timeout]);
|
|
24
|
+
this.check();
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
if (timer)
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function parseSkillFrontmatter(text) {
|
|
34
|
+
if (Buffer.byteLength(text) > SKILL_LIMITS.metadataBytes)
|
|
35
|
+
throw new ServiceError('INVALID_REQUEST', 'SKILL.md 超过大小限制。');
|
|
36
|
+
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
|
|
37
|
+
if (!match)
|
|
38
|
+
throw new ServiceError('INVALID_REQUEST', 'SKILL.md 缺少 YAML frontmatter。');
|
|
39
|
+
try {
|
|
40
|
+
const document = parseDocument(match[1], { uniqueKeys: true });
|
|
41
|
+
if (document.errors.length)
|
|
42
|
+
throw Error('yaml');
|
|
43
|
+
const value = document.toJS({ maxAliasCount: 32 });
|
|
44
|
+
if (!value || !isSkillName(value.name) || typeof value.description !== 'string' || !value.description.trim() || value.description.length > 16_384)
|
|
45
|
+
throw Error('metadata');
|
|
46
|
+
return { name: value.name, description: value.description.trim() };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new ServiceError('INVALID_REQUEST', 'SKILL.md 的名称或 YAML 简介无效。');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Shared packages, workspace-independent installation pointers, immutable versions. */
|
|
53
|
+
export class BuiltinSkills {
|
|
54
|
+
root;
|
|
55
|
+
source;
|
|
56
|
+
provider;
|
|
57
|
+
ready;
|
|
58
|
+
refreshing;
|
|
59
|
+
constructor(_directories, _serviceId, options = {}) {
|
|
60
|
+
this.root = resolve(options.root ?? join(homedir(), '.openworkgraph', 'skills'));
|
|
61
|
+
this.source = { ...(options.source ?? DEFAULT_SKILL_SOURCE) };
|
|
62
|
+
this.provider = options.provider ?? new GitHubSkillSource();
|
|
63
|
+
}
|
|
64
|
+
initialize() {
|
|
65
|
+
return this.ready ??= (async () => {
|
|
66
|
+
await fs.mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
67
|
+
const stat = await fs.lstat(this.root);
|
|
68
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
69
|
+
throw new ServiceError('INVALID_REQUEST', '技能管理目录不能是链接。');
|
|
70
|
+
await fs.chmod(this.root, 0o700);
|
|
71
|
+
await this.directory('versions');
|
|
72
|
+
await this.directory('staging');
|
|
73
|
+
})();
|
|
74
|
+
}
|
|
75
|
+
async checked(relative) {
|
|
76
|
+
const parts = relative.split('/');
|
|
77
|
+
let path = this.root;
|
|
78
|
+
const rootStat = await fs.lstat(path);
|
|
79
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
80
|
+
throw new ServiceError('INVALID_REQUEST', '技能管理目录无效。');
|
|
81
|
+
for (const part of parts) {
|
|
82
|
+
if (!part || part === '.' || part === '..' || part.includes('\\') || part.includes('\0'))
|
|
83
|
+
throw new ServiceError('INVALID_REQUEST', '技能管理路径无效。');
|
|
84
|
+
path = join(path, part);
|
|
85
|
+
try {
|
|
86
|
+
if ((await fs.lstat(path)).isSymbolicLink())
|
|
87
|
+
throw new ServiceError('INVALID_REQUEST', '技能管理路径不能是链接。');
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (!isMissing(error))
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
96
|
+
async directory(relative) {
|
|
97
|
+
const path = await this.checked(relative);
|
|
98
|
+
await fs.mkdir(path, { recursive: true, mode: 0o700 });
|
|
99
|
+
if (!(await fs.lstat(path)).isDirectory())
|
|
100
|
+
throw new ServiceError('INVALID_REQUEST', '技能管理目录无效。');
|
|
101
|
+
await fs.chmod(path, 0o700);
|
|
102
|
+
return path;
|
|
103
|
+
}
|
|
104
|
+
async readJson(relative) {
|
|
105
|
+
try {
|
|
106
|
+
const path = await this.checked(relative);
|
|
107
|
+
if ((await fs.stat(path)).size > 16 * 1024 * 1024)
|
|
108
|
+
throw new ServiceError('INVALID_REQUEST', '技能管理记录过大。');
|
|
109
|
+
return JSON.parse(await fs.readFile(path, 'utf8'));
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (isMissing(error))
|
|
113
|
+
return undefined;
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async atomicJson(relative, value) {
|
|
118
|
+
const target = await this.checked(relative);
|
|
119
|
+
const temp = target + '.' + randomUUID() + '.tmp';
|
|
120
|
+
try {
|
|
121
|
+
const file = await fs.open(temp, 'wx', 0o600);
|
|
122
|
+
try {
|
|
123
|
+
await file.writeFile(JSON.stringify(value));
|
|
124
|
+
await file.sync();
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await file.close();
|
|
128
|
+
}
|
|
129
|
+
await fs.rename(temp, target);
|
|
130
|
+
if (process.platform !== 'win32') {
|
|
131
|
+
const parent = await fs.open(dirname(target), 'r');
|
|
132
|
+
try {
|
|
133
|
+
await parent.sync();
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await parent.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
await fs.rm(temp, { force: true });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async state() {
|
|
145
|
+
const value = await this.readJson('installed.json');
|
|
146
|
+
if (!value)
|
|
147
|
+
return { version: 1, items: {} };
|
|
148
|
+
if (value.version !== 1 || !value.items || typeof value.items !== 'object' || Array.isArray(value.items))
|
|
149
|
+
throw new ServiceError('INVALID_REQUEST', '技能安装记录损坏。');
|
|
150
|
+
for (const [id, item] of Object.entries(value.items))
|
|
151
|
+
if (item.skillId !== id || !Number.isSafeInteger(item.revision) || item.revision < 0 || (item.packageVersion && !/^[a-f0-9]{64}$/.test(item.packageVersion)))
|
|
152
|
+
throw new ServiceError('INVALID_REQUEST', '技能安装记录损坏。');
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
async claimLock(relative) {
|
|
156
|
+
const target = await this.checked(relative);
|
|
157
|
+
const parent = relative.includes('/') ? relative.slice(0, relative.lastIndexOf('/')) + '/' : '';
|
|
158
|
+
const prepared = parent + '.owner-' + randomUUID();
|
|
159
|
+
await this.directory(prepared);
|
|
160
|
+
try {
|
|
161
|
+
const owner = { pid: process.pid, hostname: hostname(), token: randomUUID() };
|
|
162
|
+
await this.atomicJson(prepared + '/owner.json', owner);
|
|
163
|
+
try {
|
|
164
|
+
await fs.lstat(target);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (!isMissing(error))
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
await fs.rename(await this.checked(prepared), target);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code ?? '')) {
|
|
177
|
+
try {
|
|
178
|
+
if ((await fs.lstat(target)).isDirectory())
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (isMissing(error))
|
|
183
|
+
return false;
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
await fs.rm(await this.checked(prepared), { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async lockOwner(relative) {
|
|
195
|
+
const owner = await this.readJson(relative + '/owner.json');
|
|
196
|
+
if (!owner || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || typeof owner.token !== 'string' || typeof owner.hostname !== 'string')
|
|
197
|
+
return undefined;
|
|
198
|
+
return owner;
|
|
199
|
+
}
|
|
200
|
+
deadOwner(owner) {
|
|
201
|
+
if (owner.hostname !== hostname())
|
|
202
|
+
return false;
|
|
203
|
+
try {
|
|
204
|
+
process.kill(owner.pid, 0);
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
return error.code === 'ESRCH';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async recoverLock(relative, depth = 0) {
|
|
212
|
+
const owner = await this.lockOwner(relative);
|
|
213
|
+
if (!owner || !this.deadOwner(owner) || depth > 4)
|
|
214
|
+
return false;
|
|
215
|
+
const recovery = relative + '/.recovery';
|
|
216
|
+
try {
|
|
217
|
+
if (!await this.claimLock(recovery)) {
|
|
218
|
+
await this.recoverLock(recovery, depth + 1);
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
if (isMissing(error))
|
|
224
|
+
return false;
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
let moved = false;
|
|
228
|
+
try {
|
|
229
|
+
const current = await this.lockOwner(relative);
|
|
230
|
+
if (!current || current.token !== owner.token || !this.deadOwner(current))
|
|
231
|
+
return false;
|
|
232
|
+
const retired = relative + '.retired-' + randomUUID();
|
|
233
|
+
await fs.rename(await this.checked(relative), await this.checked(retired));
|
|
234
|
+
moved = true;
|
|
235
|
+
await fs.rm(await this.checked(retired), { recursive: true, force: true });
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
if (!moved)
|
|
240
|
+
await fs.rm(await this.checked(recovery), { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async lock(work) {
|
|
244
|
+
await this.initialize();
|
|
245
|
+
const started = Date.now();
|
|
246
|
+
for (;;) {
|
|
247
|
+
if (await this.claimLock('installation.lock'))
|
|
248
|
+
break;
|
|
249
|
+
if (await this.recoverLock('installation.lock'))
|
|
250
|
+
continue;
|
|
251
|
+
if (Date.now() - started > 30_000)
|
|
252
|
+
throw new ServiceError('CONFLICT', '技能操作正在其他工作空间进行,或无法确认锁进程已退出,请稍后重试。', { retryable: true });
|
|
253
|
+
await delay(50);
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
// Only the lock holder may clean interrupted downloads. Published versions are retained.
|
|
257
|
+
for (const entry of await fs.readdir(await this.checked('staging')))
|
|
258
|
+
if (/^install-[a-f0-9-]+$/.test(entry))
|
|
259
|
+
await fs.rm(await this.checked('staging/' + entry), { recursive: true, force: true });
|
|
260
|
+
return await work();
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
const retired = 'installation.lock.retired-' + randomUUID();
|
|
264
|
+
await fs.rename(await this.checked('installation.lock'), await this.checked(retired));
|
|
265
|
+
await fs.rm(await this.checked(retired), { recursive: true, force: true });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async download(pkg, commitSha, file, deadline) {
|
|
269
|
+
const read = () => this.provider.read(this.source, commitSha, this.source.directory + '/' + pkg.directory + '/' + file.path);
|
|
270
|
+
const bytes = Buffer.from(await (deadline ? deadline.wait(read) : read()));
|
|
271
|
+
if (bytes.length !== file.size || bytes.length > SKILL_LIMITS.fileBytes || gitBlobSha(bytes) !== file.sha)
|
|
272
|
+
throw new ServiceError('INVALID_REQUEST', '技能文件大小或内容摘要校验失败。');
|
|
273
|
+
return bytes;
|
|
274
|
+
}
|
|
275
|
+
async metadata(pkg, commitSha, deadline) {
|
|
276
|
+
validatePackageFiles(pkg);
|
|
277
|
+
const read = async (name) => {
|
|
278
|
+
const file = pkg.files.find(file => file.path === name);
|
|
279
|
+
if (file.size > SKILL_LIMITS.metadataBytes)
|
|
280
|
+
throw new ServiceError('INVALID_REQUEST', '技能说明或配置超过大小限制。');
|
|
281
|
+
return (await this.download(pkg, commitSha, file, deadline)).toString('utf8');
|
|
282
|
+
};
|
|
283
|
+
const [skill, config, readme] = await Promise.all([read('SKILL.md'), read('config.json'), read('README.md')]);
|
|
284
|
+
if (!readme.trim())
|
|
285
|
+
throw new ServiceError('INVALID_REQUEST', '技能包 README.md 不能为空。');
|
|
286
|
+
return { ...parseSkillFrontmatter(skill), config: parseSkillConfig(config) };
|
|
287
|
+
}
|
|
288
|
+
refresh() {
|
|
289
|
+
return this.refreshing ??= (async () => {
|
|
290
|
+
const deadline = new SkillDeadline(SKILL_LIMITS.catalogMs);
|
|
291
|
+
const snapshot = await deadline.wait(() => this.provider.snapshot(this.source));
|
|
292
|
+
if (!/^[a-f0-9]{40}$/.test(snapshot.commitSha) || snapshot.packages.length > 500)
|
|
293
|
+
throw new ServiceError('INVALID_REQUEST', '技能来源目录无效。');
|
|
294
|
+
const packages = [];
|
|
295
|
+
const seen = new Set();
|
|
296
|
+
for (const pkg of snapshot.packages) {
|
|
297
|
+
deadline.check();
|
|
298
|
+
if (seen.has(pkg.directory))
|
|
299
|
+
throw new ServiceError('INVALID_REQUEST', '技能来源含重复目录。');
|
|
300
|
+
seen.add(pkg.directory);
|
|
301
|
+
const item = { skillId: this.source.id + ':' + pkg.directory, source: this.source, directory: pkg.directory, name: pkg.directory, description: '', installed: false, revision: 0, packageVersion: packageDigest(pkg.files), commitSha: snapshot.commitSha, updateAvailable: false, configuration: 'none' };
|
|
302
|
+
try {
|
|
303
|
+
const metadata = await this.metadata(pkg, snapshot.commitSha, deadline);
|
|
304
|
+
item.name = metadata.name;
|
|
305
|
+
item.description = metadata.description;
|
|
306
|
+
item.configuration = metadata.config.environment.length ? 'required' : 'none';
|
|
307
|
+
packages.push({ ...pkg, item, config: metadata.config });
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
item.error = error instanceof ServiceError ? error.message : '无法读取技能包,请刷新重试。';
|
|
311
|
+
packages.push({ ...pkg, item });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
deadline.check();
|
|
315
|
+
const cache = { source: this.source, fetchedAt: Date.now(), packages };
|
|
316
|
+
await this.atomicJson('catalog-' + key(JSON.stringify(this.source)) + '.json', cache);
|
|
317
|
+
return cache;
|
|
318
|
+
})().finally(() => { this.refreshing = undefined; });
|
|
319
|
+
}
|
|
320
|
+
async cached(refresh = false) {
|
|
321
|
+
await this.initialize();
|
|
322
|
+
const cache = await this.readJson('catalog-' + key(JSON.stringify(this.source)) + '.json');
|
|
323
|
+
if (!refresh && cache && Date.now() - cache.fetchedAt < 5 * 60_000)
|
|
324
|
+
return { cache };
|
|
325
|
+
try {
|
|
326
|
+
return { cache: await this.refresh() };
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
return { cache, error: error instanceof ServiceError ? error.message : '技能来源暂时不可用,请检查网络后重试。' };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async catalog(refresh = false) {
|
|
333
|
+
const { cache, error } = await this.cached(refresh);
|
|
334
|
+
const state = await this.state();
|
|
335
|
+
const result = new Map();
|
|
336
|
+
for (const pkg of cache?.packages ?? []) {
|
|
337
|
+
const local = state.items[pkg.item.skillId];
|
|
338
|
+
result.set(pkg.item.skillId, local?.installed ? { ...local, updateAvailable: local.packageVersion !== pkg.item.packageVersion, ...(pkg.item.error ? { error: pkg.item.error } : {}) } : { ...pkg.item, revision: local?.revision ?? 0 });
|
|
339
|
+
}
|
|
340
|
+
for (const item of Object.values(state.items))
|
|
341
|
+
if (item.installed && !result.has(item.skillId))
|
|
342
|
+
result.set(item.skillId, item);
|
|
343
|
+
return { items: [...result.values()].sort((a, b) => a.name.localeCompare(b.name)), stale: !!error, ...(error ? { error } : {}) };
|
|
344
|
+
}
|
|
345
|
+
async installed() { await this.initialize(); return Object.values((await this.state()).items).filter(item => item.installed); }
|
|
346
|
+
versionDirectory(id, version) {
|
|
347
|
+
if (!id || id.length > 1024 || !/^[a-f0-9]{64}$/.test(version))
|
|
348
|
+
throw new ServiceError('INVALID_REQUEST', '技能身份或包版本无效。');
|
|
349
|
+
return 'versions/' + key(id) + '/' + version;
|
|
350
|
+
}
|
|
351
|
+
async packageEntry(skillId, packageVersion) {
|
|
352
|
+
await this.initialize();
|
|
353
|
+
const current = (await this.state()).items[skillId];
|
|
354
|
+
const version = packageVersion ?? (current?.installed ? current.packageVersion : undefined);
|
|
355
|
+
if (!version)
|
|
356
|
+
throw new ServiceError('NOT_FOUND', '技能尚未安装或已卸载。');
|
|
357
|
+
const relative = this.versionDirectory(skillId, version);
|
|
358
|
+
const manifest = await this.readJson(relative + '/manifest.json');
|
|
359
|
+
if (!manifest || manifest.version !== 1 || manifest.item.skillId !== skillId || manifest.item.packageVersion !== version)
|
|
360
|
+
throw new ServiceError('NOT_FOUND', '技能固定包版本不可用。');
|
|
361
|
+
validatePackageFiles({ directory: manifest.item.directory, files: manifest.files });
|
|
362
|
+
if (packageDigest(manifest.files) !== version)
|
|
363
|
+
throw new ServiceError('INVALID_REQUEST', '技能包元数据摘要无效。');
|
|
364
|
+
for (const file of manifest.files) {
|
|
365
|
+
const path = await this.checked(relative + '/package/' + file.path);
|
|
366
|
+
const stat = await fs.stat(path);
|
|
367
|
+
if (!stat.isFile() || stat.size !== file.size || gitBlobSha(await fs.readFile(path)) !== file.sha)
|
|
368
|
+
throw new ServiceError('INVALID_REQUEST', '已安装技能包完整性校验失败。');
|
|
369
|
+
}
|
|
370
|
+
const config = parseSkillConfig(await fs.readFile(await this.checked(relative + '/package/config.json'), 'utf8'));
|
|
371
|
+
return { path: await this.checked(relative + '/package/SKILL.md'), config, item: current?.installed && current.packageVersion === version ? current : manifest.item };
|
|
372
|
+
}
|
|
373
|
+
/** Holds the same cross-process lock as updates while a configuration write uses its schema. */
|
|
374
|
+
async withPackageEntry(skillId, expectedVersion, work) {
|
|
375
|
+
return this.lock(async () => {
|
|
376
|
+
const entry = await this.packageEntry(skillId);
|
|
377
|
+
if (entry.item.packageVersion !== expectedVersion)
|
|
378
|
+
throw new ServiceError('REVISION_CONFLICT', '技能包已更新,请重新打开设置后保存。');
|
|
379
|
+
return work(entry);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/** Only files declared in a validated immutable manifest or pinned source snapshot are readable. */
|
|
383
|
+
async readPackageFile(skillId, packageVersion, path) {
|
|
384
|
+
safeSkillPath(path);
|
|
385
|
+
await this.initialize();
|
|
386
|
+
const relative = this.versionDirectory(skillId, packageVersion);
|
|
387
|
+
const manifest = await this.readJson(relative + '/manifest.json');
|
|
388
|
+
if (manifest) {
|
|
389
|
+
if (manifest.version !== 1 || manifest.item.skillId !== skillId || manifest.item.packageVersion !== packageVersion)
|
|
390
|
+
throw new ServiceError('INVALID_REQUEST', '技能包元数据无效。');
|
|
391
|
+
validatePackageFiles({ directory: manifest.item.directory, files: manifest.files });
|
|
392
|
+
if (packageDigest(manifest.files) !== packageVersion)
|
|
393
|
+
throw new ServiceError('INVALID_REQUEST', '技能包元数据摘要无效。');
|
|
394
|
+
const file = manifest.files.find(file => file.path === path);
|
|
395
|
+
if (!file)
|
|
396
|
+
throw new ServiceError('NOT_FOUND', '技能包资源不存在。');
|
|
397
|
+
const target = await this.checked(relative + '/package/' + path);
|
|
398
|
+
const stat = await fs.stat(target);
|
|
399
|
+
if (!stat.isFile() || stat.size !== file.size)
|
|
400
|
+
throw new ServiceError('INVALID_REQUEST', '技能资源完整性校验失败。');
|
|
401
|
+
const bytes = await fs.readFile(target);
|
|
402
|
+
if (gitBlobSha(bytes) !== file.sha)
|
|
403
|
+
throw new ServiceError('INVALID_REQUEST', '技能资源完整性校验失败。');
|
|
404
|
+
return bytes;
|
|
405
|
+
}
|
|
406
|
+
const { cache, error } = await this.cached();
|
|
407
|
+
const pkg = cache?.packages.find(pkg => pkg.item.skillId === skillId && pkg.item.packageVersion === packageVersion);
|
|
408
|
+
if (!pkg || pkg.item.error)
|
|
409
|
+
throw new ServiceError('NOT_FOUND', error ?? '技能固定包版本或资源不可用,请刷新详情。');
|
|
410
|
+
validatePackageFiles(pkg);
|
|
411
|
+
const file = pkg.files.find(file => file.path === path);
|
|
412
|
+
if (!file)
|
|
413
|
+
throw new ServiceError('NOT_FOUND', '技能包资源不存在。');
|
|
414
|
+
return this.download(pkg, pkg.item.commitSha, file);
|
|
415
|
+
}
|
|
416
|
+
async detail(skillId, locale = 'en') {
|
|
417
|
+
if (!/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(locale))
|
|
418
|
+
throw new ServiceError('INVALID_REQUEST', '技能说明语言无效。');
|
|
419
|
+
const names = [...new Set(['README.' + locale + '.md', 'README.' + locale.split('-')[0] + '.md', 'README.md'])];
|
|
420
|
+
const current = (await this.installed()).find(item => item.skillId === skillId);
|
|
421
|
+
if (current) {
|
|
422
|
+
const entry = await this.packageEntry(skillId);
|
|
423
|
+
const relative = this.versionDirectory(skillId, current.packageVersion);
|
|
424
|
+
const manifest = (await this.readJson(relative + '/manifest.json'));
|
|
425
|
+
const name = names.find(name => manifest.files.some(file => file.path === name));
|
|
426
|
+
const cached = await this.readJson('catalog-' + key(JSON.stringify(this.source)) + '.json');
|
|
427
|
+
const remote = cached?.packages.find(pkg => pkg.item.skillId === skillId);
|
|
428
|
+
return { item: { ...entry.item, updateAvailable: !!remote && remote.item.packageVersion !== entry.item.packageVersion }, config: entry.config, readme: await fs.readFile(join(dirname(entry.path), name), 'utf8') };
|
|
429
|
+
}
|
|
430
|
+
const { cache, error } = await this.cached();
|
|
431
|
+
const pkg = cache?.packages.find(pkg => pkg.item.skillId === skillId);
|
|
432
|
+
if (!pkg)
|
|
433
|
+
throw new ServiceError('NOT_FOUND', error ?? '技能不存在。');
|
|
434
|
+
if (pkg.item.error || !pkg.config)
|
|
435
|
+
throw new ServiceError('INVALID_REQUEST', pkg.item.error ?? '技能包配置无效。');
|
|
436
|
+
const file = pkg.files.find(file => file.path === names.find(name => pkg.files.some(file => file.path === name)));
|
|
437
|
+
if (file.size > SKILL_LIMITS.metadataBytes)
|
|
438
|
+
throw new ServiceError('PAYLOAD_TOO_LARGE', '技能说明超过大小限制。');
|
|
439
|
+
return { item: { ...pkg.item, revision: (await this.state()).items[skillId]?.revision ?? 0 }, config: pkg.config, readme: (await this.download(pkg, pkg.item.commitSha, file)).toString('utf8') };
|
|
440
|
+
}
|
|
441
|
+
async install(skillId, expectedRevision) {
|
|
442
|
+
const deadline = new SkillDeadline(SKILL_LIMITS.installMs);
|
|
443
|
+
return this.lock(async () => {
|
|
444
|
+
deadline.check();
|
|
445
|
+
const state = await this.state();
|
|
446
|
+
const current = state.items[skillId];
|
|
447
|
+
this.checkRevision(current, expectedRevision);
|
|
448
|
+
const { cache, error } = await deadline.wait(() => this.cached(true));
|
|
449
|
+
// A stale catalog may be shown, but must never be used to publish an installation.
|
|
450
|
+
if (error)
|
|
451
|
+
throw new ServiceError('NOT_FOUND', error, { retryable: true });
|
|
452
|
+
const pkg = cache?.packages.find(pkg => pkg.item.skillId === skillId);
|
|
453
|
+
if (!pkg)
|
|
454
|
+
throw new ServiceError('NOT_FOUND', '技能不存在。');
|
|
455
|
+
if (pkg.item.error)
|
|
456
|
+
throw new ServiceError('INVALID_REQUEST', pkg.item.error);
|
|
457
|
+
if (current?.installed && current.packageVersion === pkg.item.packageVersion)
|
|
458
|
+
return current;
|
|
459
|
+
const item = { ...pkg.item, installed: true, revision: (current?.revision ?? 0) + 1, updateAvailable: false };
|
|
460
|
+
const target = this.versionDirectory(skillId, item.packageVersion);
|
|
461
|
+
const staging = 'staging/install-' + randomUUID();
|
|
462
|
+
await this.directory(staging + '/package');
|
|
463
|
+
try {
|
|
464
|
+
for (const file of pkg.files) {
|
|
465
|
+
const bytes = await this.download(pkg, item.commitSha, file, deadline);
|
|
466
|
+
deadline.check();
|
|
467
|
+
const path = await this.checked(staging + '/package/' + file.path);
|
|
468
|
+
await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
469
|
+
await fs.writeFile(path, bytes, { flag: 'wx', mode: file.mode === '100755' ? 0o700 : 0o600 });
|
|
470
|
+
}
|
|
471
|
+
parseSkillFrontmatter(await fs.readFile(await this.checked(staging + '/package/SKILL.md'), 'utf8'));
|
|
472
|
+
parseSkillConfig(await fs.readFile(await this.checked(staging + '/package/config.json'), 'utf8'));
|
|
473
|
+
deadline.check();
|
|
474
|
+
await this.atomicJson(staging + '/manifest.json', { version: 1, item, files: pkg.files, installedAt: new Date().toISOString() });
|
|
475
|
+
await this.directory('versions/' + key(skillId));
|
|
476
|
+
const existing = await this.readJson(target + '/manifest.json');
|
|
477
|
+
deadline.check();
|
|
478
|
+
if (!existing)
|
|
479
|
+
await fs.rename(await this.checked(staging), await this.checked(target));
|
|
480
|
+
else
|
|
481
|
+
await this.packageEntry(skillId, item.packageVersion);
|
|
482
|
+
deadline.check();
|
|
483
|
+
state.items[skillId] = item;
|
|
484
|
+
await this.atomicJson('installed.json', state);
|
|
485
|
+
return item;
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
await fs.rm(await this.checked(staging), { recursive: true, force: true });
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
async uninstall(skillId, expectedRevision) {
|
|
493
|
+
return this.lock(async () => {
|
|
494
|
+
const state = await this.state();
|
|
495
|
+
const current = state.items[skillId];
|
|
496
|
+
this.checkRevision(current, expectedRevision);
|
|
497
|
+
if (!current)
|
|
498
|
+
throw new ServiceError('NOT_FOUND', '技能不存在。');
|
|
499
|
+
if (!current.installed)
|
|
500
|
+
return current;
|
|
501
|
+
const item = { ...current, installed: false, revision: current.revision + 1, updateAvailable: false };
|
|
502
|
+
state.items[skillId] = item;
|
|
503
|
+
await this.atomicJson('installed.json', state);
|
|
504
|
+
return item;
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
checkRevision(item, expected) {
|
|
508
|
+
if (!Number.isSafeInteger(expected) || expected < 0)
|
|
509
|
+
throw new ServiceError('INVALID_REQUEST', '技能操作需要有效的 expectedRevision。');
|
|
510
|
+
if ((item?.revision ?? 0) !== expected)
|
|
511
|
+
throw new ServiceError('REVISION_CONFLICT', '技能安装状态已改变,请刷新后重试。');
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
//# sourceMappingURL=builtin-skills.js.map
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/graph-transfer.js
CHANGED
|
@@ -4,7 +4,7 @@ import { previewEdgeError } from "./vendor/protocol/index.js";
|
|
|
4
4
|
import { Graphs, assertProjectWritable, resourceLinks, nodeDimension } from './graphs.js';
|
|
5
5
|
import { Resources } from './resources.js';
|
|
6
6
|
import { mimeMatchesBytes, sniffMime } from './blob-store.js';
|
|
7
|
-
import { checkedJson, PluginRegistry } from './plugins.js';
|
|
7
|
+
import { checkedJson, PluginRegistry, portableSkillContent } from './plugins.js';
|
|
8
8
|
import { atomic } from './persistence/database.js';
|
|
9
9
|
import { canonicalJsonHash } from './persistence/repositories.js';
|
|
10
10
|
import { ServiceError } from './errors.js';
|
|
@@ -13,7 +13,10 @@ const DEFAULT_LIMITS = { maxBundleBytes: WORKGRAPH_BUNDLE_MAX_BYTES, maxResource
|
|
|
13
13
|
const core = new Set(['text', 'image', 'document', 'video', 'file', 'preview', 'execution', 'group']);
|
|
14
14
|
const key = (id, version) => JSON.stringify([id, version]);
|
|
15
15
|
function portableNode(node) {
|
|
16
|
-
|
|
16
|
+
const result = structuredClone(node);
|
|
17
|
+
if (core.has(node.type))
|
|
18
|
+
result.content = portableSkillContent(result.content);
|
|
19
|
+
return result;
|
|
17
20
|
}
|
|
18
21
|
function invalid(message) { throw new ServiceError('INVALID_REQUEST', message); }
|
|
19
22
|
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
@@ -181,6 +184,8 @@ export class GraphTransfer {
|
|
|
181
184
|
if (nodes.has(n.id) || typeof n.readOnly !== 'boolean' || !Number.isFinite(n.x) || !Number.isFinite(n.y) || Math.abs(n.x) > 1e9 || Math.abs(n.y) > 1e9)
|
|
182
185
|
invalid('Invalid or duplicate node');
|
|
183
186
|
checkedJson(n.content, 2 * 1024 * 1024, 32);
|
|
187
|
+
if (core.has(n.type))
|
|
188
|
+
n.content = portableSkillContent(n.content);
|
|
184
189
|
if (n.width !== undefined)
|
|
185
190
|
nodeDimension(n.width);
|
|
186
191
|
if (n.height !== undefined)
|
package/dist/inputs.d.ts
CHANGED
package/dist/inputs.js
CHANGED
|
@@ -58,7 +58,7 @@ export class InputPreparation {
|
|
|
58
58
|
else {
|
|
59
59
|
if (!options.model || typeof options.model.model !== 'string' || !options.model.model.trim() || (options.model.reasoningEffort !== null && typeof options.model.reasoningEffort !== 'string'))
|
|
60
60
|
throw new ServiceError('MODEL_UNAVAILABLE', 'A valid model selection is required');
|
|
61
|
-
payload = { ...base, model: { model: options.model.model, reasoningEffort: options.model.reasoningEffort }, ...(options.imageRoute ? { imageRoute: options.imageRoute, inputMode } : {}) };
|
|
61
|
+
payload = { ...base, model: { model: options.model.model, reasoningEffort: options.model.reasoningEffort }, ...(options.skills?.length ? { skills: structuredClone(options.skills) } : {}), ...(options.imageRoute ? { imageRoute: options.imageRoute, inputMode } : {}) };
|
|
62
62
|
}
|
|
63
63
|
// Include exact source revisions and edge provenance in the digest without sending ancestors to the model.
|
|
64
64
|
const inputDigest = createHash('sha256').update(JSON.stringify({ graphId, nodeId, ...payload, sources: preview.sources })).digest('hex');
|
package/dist/plugins.d.ts
CHANGED
|
@@ -34,6 +34,10 @@ export interface PluginContract {
|
|
|
34
34
|
/** Bounded pure JSON only: no getters, functions, cycles or exotic prototypes. */
|
|
35
35
|
export declare function checkedJson(value: unknown, maxBytes?: number, maxDepth?: number): Json;
|
|
36
36
|
export declare function readField(content: Json, field: string): Json | undefined;
|
|
37
|
+
/** Validate explicit references against the exact prompt occurrence, including boundaries. */
|
|
38
|
+
export declare function validSkillContent(content: Json): boolean;
|
|
39
|
+
/** Keep explicit Codex provenance while removing host paths; re-selection is required. */
|
|
40
|
+
export declare function portableSkillContent(content: Json): Json;
|
|
37
41
|
export interface PluginInformation {
|
|
38
42
|
state: 'available' | 'missing' | 'incompatible' | 'invalid';
|
|
39
43
|
typeId: string;
|
package/dist/plugins.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSkillName } from "./vendor/protocol/index.js";
|
|
1
2
|
import { ServiceError } from './errors.js';
|
|
2
3
|
const forbidden = new Set(['__proto__', 'prototype', 'constructor']);
|
|
3
4
|
const builtins = new Set(['text', 'image', 'document', 'video', 'file', 'preview', 'execution', 'group']);
|
|
@@ -154,6 +155,37 @@ function validateContract(input) {
|
|
|
154
155
|
}
|
|
155
156
|
return c;
|
|
156
157
|
}
|
|
158
|
+
/** Validate explicit references against the exact prompt occurrence, including boundaries. */
|
|
159
|
+
export function validSkillContent(content) {
|
|
160
|
+
if (!object(content) || content.skillReferences === undefined)
|
|
161
|
+
return true;
|
|
162
|
+
const refs = content.skillReferences;
|
|
163
|
+
if (!Array.isArray(refs) || refs.length > 1024 || typeof content.prompt !== 'string')
|
|
164
|
+
return false;
|
|
165
|
+
const prompt = content.prompt;
|
|
166
|
+
let end = 0;
|
|
167
|
+
for (const value of refs) {
|
|
168
|
+
if (!object(value) || Object.keys(value).some(key => !['skillId', 'source', 'name', 'start', 'end'].includes(key)))
|
|
169
|
+
return false;
|
|
170
|
+
const ref = value;
|
|
171
|
+
if (typeof ref.skillId !== 'string' || !ref.skillId.trim() || ref.skillId.length > 4096 || /[\x00-\x1f]/.test(ref.skillId) ||
|
|
172
|
+
!['openworkgraph', 'codex'].includes(ref.source) || !isSkillName(ref.name) ||
|
|
173
|
+
!Number.isSafeInteger(ref.start) || !Number.isSafeInteger(ref.end) || ref.start < end || ref.end > prompt.length ||
|
|
174
|
+
prompt.slice(ref.start, ref.end) !== '$' + ref.name ||
|
|
175
|
+
(ref.start > 0 && !/\s/.test(prompt[ref.start - 1])) || (ref.end < prompt.length && !/[\s.,!?;,。!?;]/.test(prompt[ref.end])))
|
|
176
|
+
return false;
|
|
177
|
+
end = ref.end;
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
/** Keep explicit Codex provenance while removing host paths; re-selection is required. */
|
|
182
|
+
export function portableSkillContent(content) {
|
|
183
|
+
if (!object(content) || content.skillReferences === undefined)
|
|
184
|
+
return content;
|
|
185
|
+
if (!validSkillContent(content))
|
|
186
|
+
invalid('Invalid skill references');
|
|
187
|
+
return { ...content, skillReferences: content.skillReferences.map(ref => object(ref) && ref.source === 'codex' ? { ...ref, skillId: 'codex-unresolved:' + ref.name } : ref) };
|
|
188
|
+
}
|
|
157
189
|
export class PluginRegistry {
|
|
158
190
|
db;
|
|
159
191
|
constructor(db) {
|
|
@@ -209,7 +241,7 @@ export class PluginRegistry {
|
|
|
209
241
|
}
|
|
210
242
|
if (!contract)
|
|
211
243
|
return { state: 'missing', typeId: type, schemaVersion, content, reason: 'Resource contract is unavailable', contract: null };
|
|
212
|
-
const valid = matches(contract.contentSchema, content);
|
|
244
|
+
const valid = matches(contract.contentSchema, content) && (!builtins.has(type) || validSkillContent(content));
|
|
213
245
|
return { state: valid ? 'available' : 'invalid', typeId: type, schemaVersion, content, reason: valid ? null : 'Content does not match schema', contract };
|
|
214
246
|
}
|
|
215
247
|
/** Pure copy-on-success migration. Caller persists new node version atomically; old version is never changed. */
|
|
@@ -5,6 +5,7 @@ import { Runs } from './runs.js';
|
|
|
5
5
|
import { ProjectProbe } from './project-probe.js';
|
|
6
6
|
import { type DataDirectories } from './directories.js';
|
|
7
7
|
import { RunContexts } from './run-context.js';
|
|
8
|
+
import type { SkillSessions } from './skill-sessions.js';
|
|
8
9
|
export declare function backendReply(interaction: Interaction, answer: Json): BackendReply;
|
|
9
10
|
/** Trusted host bridge. Never accepts browser paths or creates isolation evidence. */
|
|
10
11
|
export declare class RuntimeBackend implements SchedulerBackend {
|
|
@@ -12,9 +13,10 @@ export declare class RuntimeBackend implements SchedulerBackend {
|
|
|
12
13
|
readonly dirs: DataDirectories;
|
|
13
14
|
readonly adapter: BackendAdapter;
|
|
14
15
|
readonly probe: ProjectProbe;
|
|
16
|
+
readonly skillSessions?: SkillSessions | undefined;
|
|
15
17
|
private readonly contexts;
|
|
16
18
|
readonly runContexts: RunContexts;
|
|
17
|
-
constructor(runs: Runs, dirs: DataDirectories, adapter: BackendAdapter, probe?: ProjectProbe);
|
|
19
|
+
constructor(runs: Runs, dirs: DataDirectories, adapter: BackendAdapter, probe?: ProjectProbe, skillSessions?: SkillSessions | undefined);
|
|
18
20
|
private snapshot;
|
|
19
21
|
start(run: Run, snapshot: InputSnapshot, context: BackendContext): Promise<void>;
|
|
20
22
|
queryHistory(runId: string, query: string): string;
|